DropdownMenu
ui overlaymenu
Headless Popover-Menü: render-prop-Trigger, Items und Separatoren. Schließt bei Klick-außerhalb und Escape. Ausrichtung (links/rechts), Aufklapprichtung (down/up) und Mindestbreite konfigurierbar.
Gewählt: —
Abhängigkeiten
Barrierefreiheit
- Menü: role="menu" + aria-label; Items: role="menuitem"; Separator: role="separator".
- Trigger sollte aria-haspopup="menu" und aria-expanded setzen (siehe FilterDropdown).
- Hinweis: keine Roving-Tabindex-/Pfeiltasten-Navigation — bei Bedarf ergänzen oder React Aria <Menu> erwägen.
Beispiele
Aktionsmenü
<DropdownMenu
ariaLabel="Aktionen"
trigger={({ open, toggle }) => (
<Button aria-expanded={open} onClick={toggle}>Mehr</Button>
)}
>
<DropdownMenuItem onSelect={rename}>Umbenennen</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={remove}>Löschen</DropdownMenuItem>
</DropdownMenu> Genutzte Tokens · 11
--bg--border--elev-overlay--fg--font-body--muted--radius-md--radius-sm--space-2--surface--text-sm
Quelldateien
components/ui/DropdownMenu.tsx tsx
import {
createContext,
useContext,
useEffect,
useRef,
useState,
type ReactNode,
} from 'react';
import styles from './DropdownMenu.module.css';
interface DropdownContext {
close: () => void;
}
const Ctx = createContext<DropdownContext | null>(null);
interface DropdownMenuProps {
ariaLabel: string;
align?: 'left' | 'right';
/** Aufklapp-Richtung. `'down'` (Default) öffnet unter dem Trigger, `'up'`
* darüber — z. B. für eine Aktionsleiste am unteren Rand. */
direction?: 'down' | 'up';
/** Feste Mindestbreite in px (überschreibt das CSS-Default von 180px). Sinnvoll,
* wenn der Inhalt seine Breite ändert und das Menü nicht springen soll. */
minWidth?: number;
trigger: (state: { open: boolean; toggle: () => void }) => ReactNode;
children: ReactNode;
}
export function DropdownMenu({
ariaLabel,
align = 'right',
direction = 'down',
minWidth,
trigger,
children,
}: DropdownMenuProps) {
const [open, setOpen] = useState(false);
const wrapRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onMouseDown = (e: MouseEvent) => {
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) setOpen(false);
};
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false);
};
document.addEventListener('mousedown', onMouseDown);
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('mousedown', onMouseDown);
document.removeEventListener('keydown', onKeyDown);
};
}, [open]);
const close = () => setOpen(false);
const toggle = () => setOpen((v) => !v);
return (
<div className={styles.wrap} ref={wrapRef}>
{trigger({ open, toggle })}
{open && (
<div
className={`${styles.menu} ${align === 'left' ? styles.alignLeft : styles.alignRight} ${direction === 'up' ? styles.dropUp : ''}`}
style={minWidth != null ? { minWidth } : undefined}
role="menu"
aria-label={ariaLabel}
>
<Ctx.Provider value={{ close }}>{children}</Ctx.Provider>
</div>
)}
</div>
);
}
interface DropdownMenuItemProps {
onSelect?: () => void;
disabled?: boolean;
title?: string;
/** Ob das Menü nach der Auswahl schließt. Default `true`. Auf `false` setzen
* für In-Place-Aktionen (z. B. Theme umschalten), deren Wirkung man bei
* offenem Menü sehen will. */
closeOnSelect?: boolean;
children: ReactNode;
}
export function DropdownMenuItem({
onSelect,
disabled,
title,
closeOnSelect = true,
children,
}: DropdownMenuItemProps) {
const ctx = useContext(Ctx);
return (
<button
type="button"
role="menuitem"
className={styles.item}
disabled={disabled}
title={title}
onClick={() => {
if (disabled) return;
onSelect?.();
if (closeOnSelect) ctx?.close();
}}
>
{children}
</button>
);
}
export function DropdownMenuSeparator() {
return <div role="separator" className={styles.separator} />;
}
components/ui/DropdownMenu.module.css css
.wrap {
position: relative;
display: inline-flex;
}
.menu {
position: absolute;
top: calc(100% + 6px);
min-width: 180px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: var(--elev-overlay);
padding: 6px;
z-index: 50;
/* Reset typography that may be inherited from parent containers
(e.g. uppercase / mono / wide tracking on section titles). */
font-family: var(--font-body);
font-size: var(--text-sm);
letter-spacing: normal;
text-transform: none;
color: var(--fg);
}
.alignRight {
right: 0;
}
.alignLeft {
left: 0;
}
/* Nach oben aufklappen (Trigger sitzt am unteren Rand). */
.dropUp {
top: auto;
bottom: calc(100% + 6px);
}
.item {
display: flex;
align-items: center;
gap: var(--space-2);
width: 100%;
padding: 8px 10px;
border: 0;
background: transparent;
border-radius: var(--radius-sm);
font: inherit;
color: inherit;
cursor: pointer;
text-align: left;
white-space: nowrap;
}
.item:hover {
background: var(--bg);
}
.item:focus-visible {
outline: none;
background: var(--bg);
}
.item:disabled {
color: var(--muted);
cursor: not-allowed;
opacity: 0.55;
}
.item:disabled:hover {
background: transparent;
}
.separator {
height: 1px;
margin: 4px 6px;
background: var(--border);
}
Holen
# MCP (Claude Code / Cursor)
get_component({ name: "dropdown-menu" })
# Registry direkt
https://www.ekklesi.tools/design/registry/components/dropdown-menu.json