Component
Dropdown Menu Destructive
A menu containing destructive actions, styled with the restrained semantic destructive token and quarantined below a separator — clearly dangerous, never neon.
- Component
- React
- TSX
- Tailwind CSS
- MIT
Preview
Live component
9:41 100%
devsnips.dev/library/react/components/dropdowns/dropdown-menu-destructive/ fluid · no fixed width 100%
Install
Add to your project
npx devsnips add React/Components/Dropdowns/dropdown-menu-destructive React/Components/Dropdowns/dropdown-menu-destructive import {
createContext,
useContext,
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
} from "react";
import type {
ButtonHTMLAttributes,
HTMLAttributes,
KeyboardEvent as ReactKeyboardEvent,
MouseEvent as ReactMouseEvent,
ReactNode,
RefObject,
} from "react";
/**
* DevSnips React Dropdown Menu — Destructive.
*
* The shared menu core with a destructive action (`destructive` prop on
* `<DropdownMenuItem>`): restrained semantic destructive-token text with a
* soft destructive hover/focus surface, separated from ordinary actions by
* a `<DropdownMenuSeparator>`. Identical core + behavior as the reference
* dropdown-menu variant.
*/
function cx(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(" ");
}
export type DropdownMenuPlacement = "bottom-start" | "bottom-end" | "top-start" | "top-end";
const TRIGGER_CLASSES =
"inline-flex h-9 max-w-full items-center gap-2 rounded-[var(--ds-radius-sm)] border border-[var(--ds-color-border)] bg-[var(--ds-color-surface)] px-3 text-sm font-medium leading-5 text-[var(--ds-color-foreground)] shadow-[var(--ds-shadow-xs)] transition-colors duration-150 ease-out hover:bg-[var(--ds-color-surface-hover)] aria-expanded:bg-[var(--ds-color-surface-hover)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none";
const MENU_CLASSES =
"absolute z-40 max-h-[min(20rem,calc(100vh-2rem))] min-w-[12rem] max-w-[calc(100vw-1.5rem)] overflow-y-auto rounded-[var(--ds-radius-md)] border border-[var(--ds-color-border)] bg-[var(--ds-color-surface-elevated)] p-1 shadow-[var(--ds-shadow-md)]";
// Layout/interaction only — the text/hover/focus tone is composed per item
// kind so the destructive tone never conflicts with the default tone.
const ITEM_CLASSES =
"flex w-full items-center gap-2 rounded-[var(--ds-radius-sm)] px-2 py-1.5 text-left text-[13px] leading-5 transition-colors duration-150 ease-out focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[var(--ds-color-focus-ring)] disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none";
const ITEM_TONE_DEFAULT =
"text-[var(--ds-color-foreground)] hover:bg-[var(--ds-color-surface-hover)] focus:bg-[var(--ds-color-surface-hover)]";
const ITEM_TONE_DESTRUCTIVE =
"text-[var(--ds-color-destructive)] hover:bg-[var(--ds-color-destructive-soft)] focus:bg-[var(--ds-color-destructive-soft)]";
const ICON_SLOT_CLASSES = "inline-flex shrink-0 [&_svg]:size-4";
const SHORTCUT_CLASSES =
"ml-auto shrink-0 pl-6 text-xs leading-5 text-[var(--ds-color-muted-foreground)]";
const LABEL_CLASSES =
"px-2 pb-1 pt-1.5 text-[11px] font-medium uppercase tracking-[0.05em] text-[var(--ds-color-muted-foreground)]";
const SEPARATOR_CLASSES = "mx-1 my-1 h-px bg-[var(--ds-color-border)]";
const PLACEMENT_CLASSES: Record<DropdownMenuPlacement, string> = {
"bottom-start": "left-0 top-full mt-1.5",
"bottom-end": "right-0 top-full mt-1.5",
"top-start": "bottom-full left-0 mb-1.5",
"top-end": "bottom-full right-0 mb-1.5",
};
/** Items owned directly by `content` (excludes items of nested open submenus). */
function menuItems(content: HTMLElement): HTMLElement[] {
return Array.from(
content.querySelectorAll<HTMLElement>(
'[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]',
),
).filter(
(el) =>
el.closest('[role="menu"]') === content &&
!el.hasAttribute("disabled") &&
el.getAttribute("aria-disabled") !== "true",
);
}
function focusItem(items: HTMLElement[], index: number): void {
if (items.length === 0) return;
const wrapped = ((index % items.length) + items.length) % items.length;
items[wrapped].focus();
}
function ChevronDown({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.75}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
<path d="m6 9 6 6 6-6" />
</svg>
);
}
/* ------------------------------------------------------------------------ */
/* Root context */
/* ------------------------------------------------------------------------ */
interface DropdownMenuContextValue {
open: boolean;
initialFocus: "first" | "last";
requestOpen(next: boolean, focusTarget?: "first" | "last"): void;
closeMenu(options?: { refocus?: boolean }): void;
triggerRef: RefObject<HTMLButtonElement>;
rootRef: RefObject<HTMLDivElement>;
triggerId: string;
contentId: string;
placement: DropdownMenuPlacement;
}
const DropdownMenuContext = createContext<DropdownMenuContextValue | null>(null);
function useDropdownMenu(component: string): DropdownMenuContextValue {
const context = useContext(DropdownMenuContext);
if (!context) {
throw new Error(`<${component}> must be rendered inside <DropdownMenu>.`);
}
return context;
}
/**
* Registry of open submenus at one menu level, so pointer interaction with a
* sibling item can close the open submenu at that level. Provided by every
* menu panel (`DropdownMenuContent`, and `DropdownMenuSubContent` in the
* submenu variant).
*/
interface MenuLevelContextValue {
registerSub(close: () => void): () => void;
closeSubs(except?: () => void): void;
}
const MenuLevelContext = createContext<MenuLevelContextValue | null>(null);
function useMenuLevel(): MenuLevelContextValue {
const subsRef = useRef<Set<() => void> | null>(null);
const levelRef = useRef<MenuLevelContextValue | null>(null);
if (!subsRef.current) subsRef.current = new Set();
if (!levelRef.current) {
const subs = subsRef.current;
levelRef.current = {
registerSub(close) {
subs.add(close);
return () => {
subs.delete(close);
};
},
closeSubs(except) {
subs.forEach((close) => {
if (close !== except) close();
});
},
};
}
return levelRef.current;
}
/* ------------------------------------------------------------------------ */
/* DropdownMenu (root) */
/* ------------------------------------------------------------------------ */
export interface DropdownMenuProps {
/** Open state (controlled). */
open?: boolean;
/** Initial open state (uncontrolled). */
defaultOpen?: boolean;
/** Called whenever the menu requests to open or close. */
onOpenChange?: (open: boolean) => void;
/** Preferred placement relative to the trigger; flips to stay in the viewport. */
placement?: DropdownMenuPlacement;
className?: string;
children?: ReactNode;
}
export function DropdownMenu({
open,
defaultOpen = false,
onOpenChange,
placement = "bottom-start",
className,
children,
}: DropdownMenuProps) {
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const [initialFocus, setInitialFocus] = useState<"first" | "last">("first");
const isControlled = open !== undefined;
const actualOpen = isControlled ? open : internalOpen;
const triggerRef = useRef<HTMLButtonElement>(null);
const rootRef = useRef<HTMLDivElement>(null);
const reactId = useId();
const triggerId = `ds-menu-trigger${reactId}`;
const contentId = `ds-menu${reactId}`;
function requestOpen(next: boolean, focusTarget: "first" | "last" = "first") {
setInitialFocus(focusTarget);
if (!isControlled) setInternalOpen(next);
onOpenChange?.(next);
}
function closeMenu({ refocus = true }: { refocus?: boolean } = {}) {
requestOpen(false);
if (refocus) triggerRef.current?.focus();
}
// Outside pointer interaction closes the tree; listeners exist only while
// open and are removed as soon as it closes.
useEffect(() => {
if (!actualOpen) return;
function onPointerDown(event: PointerEvent) {
if (rootRef.current && !rootRef.current.contains(event.target as Node)) {
if (!isControlled) setInternalOpen(false);
onOpenChange?.(false);
}
}
document.addEventListener("pointerdown", onPointerDown);
return () => document.removeEventListener("pointerdown", onPointerDown);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [actualOpen]);
const value: DropdownMenuContextValue = {
open: actualOpen,
initialFocus,
requestOpen,
closeMenu,
triggerRef,
rootRef,
triggerId,
contentId,
placement,
};
return (
<DropdownMenuContext.Provider value={value}>
<div ref={rootRef} className={cx("relative inline-flex", className)}>
{children}
</div>
</DropdownMenuContext.Provider>
);
}
export default DropdownMenu;
/* ------------------------------------------------------------------------ */
/* DropdownMenuTrigger */
/* ------------------------------------------------------------------------ */
export interface DropdownMenuTriggerProps
extends ButtonHTMLAttributes<HTMLButtonElement> {
children?: ReactNode;
}
export function DropdownMenuTrigger({
children,
className,
onClick,
onKeyDown,
...rest
}: DropdownMenuTriggerProps) {
const context = useDropdownMenu("DropdownMenuTrigger");
function handleClick(event: ReactMouseEvent<HTMLButtonElement>) {
onClick?.(event);
if (event.defaultPrevented) return;
if (context.open) {
context.closeMenu();
} else {
context.requestOpen(true, "first");
}
}
function handleKeyDown(event: ReactKeyboardEvent<HTMLButtonElement>) {
onKeyDown?.(event);
if (event.defaultPrevented) return;
if (event.key === "ArrowDown") {
event.preventDefault();
context.requestOpen(true, "first");
} else if (event.key === "ArrowUp") {
event.preventDefault();
context.requestOpen(true, "last");
}
}
return (
<button
type="button"
ref={context.triggerRef}
id={context.triggerId}
aria-haspopup="menu"
aria-expanded={context.open}
aria-controls={context.open ? context.contentId : undefined}
data-state={context.open ? "open" : "closed"}
onClick={handleClick}
onKeyDown={handleKeyDown}
className={cx(TRIGGER_CLASSES, className)}
{...rest}
>
<span className="min-w-0 truncate">{children}</span>
<ChevronDown
className={cx(
"size-4 shrink-0 text-[var(--ds-color-muted-foreground)] transition-transform duration-150 ease-out motion-reduce:transition-none",
context.open && "rotate-180",
)}
/>
</button>
);
}
/* ------------------------------------------------------------------------ */
/* DropdownMenuContent */
/* ------------------------------------------------------------------------ */
export interface DropdownMenuContentProps extends HTMLAttributes<HTMLDivElement> {
children?: ReactNode;
}
export function DropdownMenuContent({
children,
className,
onKeyDown,
...rest
}: DropdownMenuContentProps) {
const context = useDropdownMenu("DropdownMenuContent");
const level = useMenuLevel();
const contentRef = useRef<HTMLDivElement>(null);
const [resolved, setResolved] = useState<DropdownMenuPlacement>(context.placement);
const [measured, setMeasured] = useState(false);
const open = context.open;
// Move focus into the menu when it opens (first item, or last when the
// trigger was invoked with ArrowUp).
useEffect(() => {
if (!open) return;
const node = contentRef.current;
if (!node) return;
const items = menuItems(node);
focusItem(items, context.initialFocus === "last" ? items.length - 1 : 0);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
// Measure against the viewport and flip the placement when the preferred
// side/alignment would overflow. Runs before paint, so the flip never
// flashes; the panel stays `invisible` until the first measurement.
useLayoutEffect(() => {
if (!open) {
setMeasured(false);
setResolved(context.placement);
return;
}
const node = contentRef.current;
const trigger = context.triggerRef.current;
if (!node || !trigger) return;
const t = trigger.getBoundingClientRect();
const c = node.getBoundingClientRect();
const margin = 8;
const below = window.innerHeight - t.bottom;
const above = t.top;
let side: "top" | "bottom" = context.placement.startsWith("top") ? "top" : "bottom";
if (side === "bottom" && below < c.height + margin && above > below) side = "top";
else if (side === "top" && above < c.height + margin && below > above) side = "bottom";
let align: "start" | "end" = context.placement.endsWith("end") ? "end" : "start";
if (align === "start" && t.left + c.width > window.innerWidth - margin && t.right - c.width >= margin) {
align = "end";
} else if (align === "end" && t.right - c.width < margin && t.left + c.width <= window.innerWidth - margin) {
align = "start";
}
setResolved(`${side}-${align}`);
setMeasured(true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, context.placement]);
function handleKeyDown(event: ReactKeyboardEvent<HTMLDivElement>) {
onKeyDown?.(event);
if (event.defaultPrevented) return;
const node = contentRef.current;
if (!node) return;
const items = menuItems(node);
const current = items.indexOf(document.activeElement as HTMLElement);
switch (event.key) {
case "ArrowDown":
event.preventDefault();
event.stopPropagation();
focusItem(items, current + 1);
break;
case "ArrowUp":
event.preventDefault();
event.stopPropagation();
focusItem(items, current - 1);
break;
case "Home":
event.preventDefault();
event.stopPropagation();
focusItem(items, 0);
break;
case "End":
event.preventDefault();
event.stopPropagation();
focusItem(items, items.length - 1);
break;
case "Escape":
event.preventDefault();
event.stopPropagation();
context.closeMenu();
break;
case "Tab":
// Let focus leave naturally; close without stealing it back.
context.closeMenu({ refocus: false });
break;
default:
break;
}
}
// Hovering a plain item closes the open submenu at this level.
function handlePointerOver(event: ReactMouseEvent<HTMLDivElement>) {
const node = contentRef.current;
if (!node) return;
const item = (event.target as HTMLElement).closest<HTMLElement>(
'[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]',
);
if (!item || item.closest('[role="menu"]') !== node) return;
if (!item.hasAttribute("data-ds-subtrigger")) level.closeSubs();
}
if (!open) return null;
return (
<MenuLevelContext.Provider value={level}>
<div
ref={contentRef}
id={context.contentId}
role="menu"
aria-labelledby={context.triggerId}
tabIndex={-1}
onKeyDown={handleKeyDown}
onPointerOver={handlePointerOver}
className={cx(MENU_CLASSES, PLACEMENT_CLASSES[resolved], !measured && "invisible", className)}
{...rest}
>
{children}
</div>
</MenuLevelContext.Provider>
);
}
/* ------------------------------------------------------------------------ */
/* DropdownMenuItem */
/* ------------------------------------------------------------------------ */
export interface DropdownMenuItemProps
extends ButtonHTMLAttributes<HTMLButtonElement> {
/** Meaningful leading icon (rendered aria-hidden). */
icon?: ReactNode;
/** Informational keyboard shortcut shown at the trailing edge (aria-hidden; exposed via aria-keyshortcuts). */
shortcut?: string;
/** Destructive action styling via the semantic destructive token. */
destructive?: boolean;
/** Whether activating the item closes the menu (default true). */
closeOnSelect?: boolean;
/** Called when the item is activated, before the menu closes. Call `event.preventDefault()` to keep the menu open. */
onSelect?: (event: ReactMouseEvent<HTMLButtonElement>) => void;
children?: ReactNode;
}
export function DropdownMenuItem({
icon,
shortcut,
destructive = false,
closeOnSelect = true,
onSelect,
onClick,
onMouseEnter,
className,
children,
...rest
}: DropdownMenuItemProps) {
const context = useDropdownMenu("DropdownMenuItem");
function handleClick(event: ReactMouseEvent<HTMLButtonElement>) {
onClick?.(event);
if (event.defaultPrevented) return;
onSelect?.(event);
if (event.defaultPrevented) return;
if (closeOnSelect) context.closeMenu();
}
function handleMouseEnter(event: ReactMouseEvent<HTMLButtonElement>) {
onMouseEnter?.(event);
event.currentTarget.focus();
}
return (
<button
type="button"
role="menuitem"
tabIndex={-1}
aria-keyshortcuts={shortcut}
onClick={handleClick}
onMouseEnter={handleMouseEnter}
className={cx(ITEM_CLASSES, destructive ? ITEM_TONE_DESTRUCTIVE : ITEM_TONE_DEFAULT, className)}
{...rest}
>
{icon ? (
<span
aria-hidden="true"
className={cx(
ICON_SLOT_CLASSES,
destructive ? "text-current" : "text-[var(--ds-color-muted-foreground)]",
)}
>
{icon}
</span>
) : null}
<span className="min-w-0 flex-1 truncate">{children}</span>
{shortcut ? (
<span aria-hidden="true" className={SHORTCUT_CLASSES}>
{shortcut}
</span>
) : null}
</button>
);
}
/* ------------------------------------------------------------------------ */
/* DropdownMenuLabel / DropdownMenuGroup / DropdownMenuSeparator */
/* ------------------------------------------------------------------------ */
export interface DropdownMenuLabelProps extends HTMLAttributes<HTMLDivElement> {
children?: ReactNode;
}
export function DropdownMenuLabel({ className, children, ...rest }: DropdownMenuLabelProps) {
return (
<div className={cx(LABEL_CLASSES, className)} {...rest}>
{children}
</div>
);
}
export interface DropdownMenuGroupProps extends HTMLAttributes<HTMLDivElement> {
children?: ReactNode;
}
export function DropdownMenuGroup({ className, children, ...rest }: DropdownMenuGroupProps) {
return (
<div role="group" className={className} {...rest}>
{children}
</div>
);
}
export function DropdownMenuSeparator({
className,
...rest
}: HTMLAttributes<HTMLDivElement>) {
return (
<div
role="separator"
aria-orientation="horizontal"
className={cx(SEPARATOR_CLASSES, className)}
{...rest}
/>
);
} /* DevSnips React — JavaScript parity build.
* Same API, behavior, and classes as code.tsx; TypeScript types removed.
* Regenerated from code.tsx — edit code.tsx and re-run the generator.
*/
import {
createContext,
useContext,
useEffect,
useId,
useLayoutEffect,
useRef,
useState
} from "react";
function cx(...parts) {
return parts.filter(Boolean).join(" ");
}
const TRIGGER_CLASSES = "inline-flex h-9 max-w-full items-center gap-2 rounded-[var(--ds-radius-sm)] border border-[var(--ds-color-border)] bg-[var(--ds-color-surface)] px-3 text-sm font-medium leading-5 text-[var(--ds-color-foreground)] shadow-[var(--ds-shadow-xs)] transition-colors duration-150 ease-out hover:bg-[var(--ds-color-surface-hover)] aria-expanded:bg-[var(--ds-color-surface-hover)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none";
const MENU_CLASSES = "absolute z-40 max-h-[min(20rem,calc(100vh-2rem))] min-w-[12rem] max-w-[calc(100vw-1.5rem)] overflow-y-auto rounded-[var(--ds-radius-md)] border border-[var(--ds-color-border)] bg-[var(--ds-color-surface-elevated)] p-1 shadow-[var(--ds-shadow-md)]";
const ITEM_CLASSES = "flex w-full items-center gap-2 rounded-[var(--ds-radius-sm)] px-2 py-1.5 text-left text-[13px] leading-5 transition-colors duration-150 ease-out focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[var(--ds-color-focus-ring)] disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none";
const ITEM_TONE_DEFAULT = "text-[var(--ds-color-foreground)] hover:bg-[var(--ds-color-surface-hover)] focus:bg-[var(--ds-color-surface-hover)]";
const ITEM_TONE_DESTRUCTIVE = "text-[var(--ds-color-destructive)] hover:bg-[var(--ds-color-destructive-soft)] focus:bg-[var(--ds-color-destructive-soft)]";
const ICON_SLOT_CLASSES = "inline-flex shrink-0 [&_svg]:size-4";
const SHORTCUT_CLASSES = "ml-auto shrink-0 pl-6 text-xs leading-5 text-[var(--ds-color-muted-foreground)]";
const LABEL_CLASSES = "px-2 pb-1 pt-1.5 text-[11px] font-medium uppercase tracking-[0.05em] text-[var(--ds-color-muted-foreground)]";
const SEPARATOR_CLASSES = "mx-1 my-1 h-px bg-[var(--ds-color-border)]";
const PLACEMENT_CLASSES = {
"bottom-start": "left-0 top-full mt-1.5",
"bottom-end": "right-0 top-full mt-1.5",
"top-start": "bottom-full left-0 mb-1.5",
"top-end": "bottom-full right-0 mb-1.5"
};
function menuItems(content) {
return Array.from(
content.querySelectorAll(
'[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]'
)
).filter(
(el) => el.closest('[role="menu"]') === content && !el.hasAttribute("disabled") && el.getAttribute("aria-disabled") !== "true"
);
}
function focusItem(items, index) {
if (items.length === 0) return;
const wrapped = (index % items.length + items.length) % items.length;
items[wrapped].focus();
}
function ChevronDown({ className }) {
return <svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.75}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
<path d="m6 9 6 6 6-6" />
</svg>;
}
const DropdownMenuContext = createContext(null);
function useDropdownMenu(component) {
const context = useContext(DropdownMenuContext);
if (!context) {
throw new Error(`<${component}> must be rendered inside <DropdownMenu>.`);
}
return context;
}
const MenuLevelContext = createContext(null);
function useMenuLevel() {
const subsRef = useRef(null);
const levelRef = useRef(null);
if (!subsRef.current) subsRef.current = /* @__PURE__ */ new Set();
if (!levelRef.current) {
const subs = subsRef.current;
levelRef.current = {
registerSub(close) {
subs.add(close);
return () => {
subs.delete(close);
};
},
closeSubs(except) {
subs.forEach((close) => {
if (close !== except) close();
});
}
};
}
return levelRef.current;
}
function DropdownMenu({
open,
defaultOpen = false,
onOpenChange,
placement = "bottom-start",
className,
children
}) {
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const [initialFocus, setInitialFocus] = useState("first");
const isControlled = open !== undefined;
const actualOpen = isControlled ? open : internalOpen;
const triggerRef = useRef(null);
const rootRef = useRef(null);
const reactId = useId();
const triggerId = `ds-menu-trigger${reactId}`;
const contentId = `ds-menu${reactId}`;
function requestOpen(next, focusTarget = "first") {
setInitialFocus(focusTarget);
if (!isControlled) setInternalOpen(next);
onOpenChange?.(next);
}
function closeMenu({ refocus = true } = {}) {
requestOpen(false);
if (refocus) triggerRef.current?.focus();
}
useEffect(() => {
if (!actualOpen) return;
function onPointerDown(event) {
if (rootRef.current && !rootRef.current.contains(event.target)) {
if (!isControlled) setInternalOpen(false);
onOpenChange?.(false);
}
}
document.addEventListener("pointerdown", onPointerDown);
return () => document.removeEventListener("pointerdown", onPointerDown);
}, [actualOpen]);
const value = {
open: actualOpen,
initialFocus,
requestOpen,
closeMenu,
triggerRef,
rootRef,
triggerId,
contentId,
placement
};
return <DropdownMenuContext.Provider value={value}>
<div ref={rootRef} className={cx("relative inline-flex", className)}>
{children}
</div>
</DropdownMenuContext.Provider>;
}
function DropdownMenuTrigger({
children,
className,
onClick,
onKeyDown,
...rest
}) {
const context = useDropdownMenu("DropdownMenuTrigger");
function handleClick(event) {
onClick?.(event);
if (event.defaultPrevented) return;
if (context.open) {
context.closeMenu();
} else {
context.requestOpen(true, "first");
}
}
function handleKeyDown(event) {
onKeyDown?.(event);
if (event.defaultPrevented) return;
if (event.key === "ArrowDown") {
event.preventDefault();
context.requestOpen(true, "first");
} else if (event.key === "ArrowUp") {
event.preventDefault();
context.requestOpen(true, "last");
}
}
return <button
type="button"
ref={context.triggerRef}
id={context.triggerId}
aria-haspopup="menu"
aria-expanded={context.open}
aria-controls={context.open ? context.contentId : undefined}
data-state={context.open ? "open" : "closed"}
onClick={handleClick}
onKeyDown={handleKeyDown}
className={cx(TRIGGER_CLASSES, className)}
{...rest}
>
<span className="min-w-0 truncate">{children}</span>
<ChevronDown
className={cx(
"size-4 shrink-0 text-[var(--ds-color-muted-foreground)] transition-transform duration-150 ease-out motion-reduce:transition-none",
context.open && "rotate-180"
)}
/>
</button>;
}
function DropdownMenuContent({
children,
className,
onKeyDown,
...rest
}) {
const context = useDropdownMenu("DropdownMenuContent");
const level = useMenuLevel();
const contentRef = useRef(null);
const [resolved, setResolved] = useState(context.placement);
const [measured, setMeasured] = useState(false);
const open = context.open;
useEffect(() => {
if (!open) return;
const node = contentRef.current;
if (!node) return;
const items = menuItems(node);
focusItem(items, context.initialFocus === "last" ? items.length - 1 : 0);
}, [open]);
useLayoutEffect(() => {
if (!open) {
setMeasured(false);
setResolved(context.placement);
return;
}
const node = contentRef.current;
const trigger = context.triggerRef.current;
if (!node || !trigger) return;
const t = trigger.getBoundingClientRect();
const c = node.getBoundingClientRect();
const margin = 8;
const below = window.innerHeight - t.bottom;
const above = t.top;
let side = context.placement.startsWith("top") ? "top" : "bottom";
if (side === "bottom" && below < c.height + margin && above > below) side = "top";
else if (side === "top" && above < c.height + margin && below > above) side = "bottom";
let align = context.placement.endsWith("end") ? "end" : "start";
if (align === "start" && t.left + c.width > window.innerWidth - margin && t.right - c.width >= margin) {
align = "end";
} else if (align === "end" && t.right - c.width < margin && t.left + c.width <= window.innerWidth - margin) {
align = "start";
}
setResolved(`${side}-${align}`);
setMeasured(true);
}, [open, context.placement]);
function handleKeyDown(event) {
onKeyDown?.(event);
if (event.defaultPrevented) return;
const node = contentRef.current;
if (!node) return;
const items = menuItems(node);
const current = items.indexOf(document.activeElement);
switch (event.key) {
case "ArrowDown":
event.preventDefault();
event.stopPropagation();
focusItem(items, current + 1);
break;
case "ArrowUp":
event.preventDefault();
event.stopPropagation();
focusItem(items, current - 1);
break;
case "Home":
event.preventDefault();
event.stopPropagation();
focusItem(items, 0);
break;
case "End":
event.preventDefault();
event.stopPropagation();
focusItem(items, items.length - 1);
break;
case "Escape":
event.preventDefault();
event.stopPropagation();
context.closeMenu();
break;
case "Tab":
context.closeMenu({ refocus: false });
break;
default:
break;
}
}
function handlePointerOver(event) {
const node = contentRef.current;
if (!node) return;
const item = event.target.closest(
'[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]'
);
if (!item || item.closest('[role="menu"]') !== node) return;
if (!item.hasAttribute("data-ds-subtrigger")) level.closeSubs();
}
if (!open) return null;
return <MenuLevelContext.Provider value={level}>
<div
ref={contentRef}
id={context.contentId}
role="menu"
aria-labelledby={context.triggerId}
tabIndex={-1}
onKeyDown={handleKeyDown}
onPointerOver={handlePointerOver}
className={cx(MENU_CLASSES, PLACEMENT_CLASSES[resolved], !measured && "invisible", className)}
{...rest}
>
{children}
</div>
</MenuLevelContext.Provider>;
}
function DropdownMenuItem({
icon,
shortcut,
destructive = false,
closeOnSelect = true,
onSelect,
onClick,
onMouseEnter,
className,
children,
...rest
}) {
const context = useDropdownMenu("DropdownMenuItem");
function handleClick(event) {
onClick?.(event);
if (event.defaultPrevented) return;
onSelect?.(event);
if (event.defaultPrevented) return;
if (closeOnSelect) context.closeMenu();
}
function handleMouseEnter(event) {
onMouseEnter?.(event);
event.currentTarget.focus();
}
return <button
type="button"
role="menuitem"
tabIndex={-1}
aria-keyshortcuts={shortcut}
onClick={handleClick}
onMouseEnter={handleMouseEnter}
className={cx(ITEM_CLASSES, destructive ? ITEM_TONE_DESTRUCTIVE : ITEM_TONE_DEFAULT, className)}
{...rest}
>
{icon ? <span
aria-hidden="true"
className={cx(
ICON_SLOT_CLASSES,
destructive ? "text-current" : "text-[var(--ds-color-muted-foreground)]"
)}
>
{icon}
</span> : null}
<span className="min-w-0 flex-1 truncate">{children}</span>
{shortcut ? <span aria-hidden="true" className={SHORTCUT_CLASSES}>
{shortcut}
</span> : null}
</button>;
}
function DropdownMenuLabel({ className, children, ...rest }) {
return <div className={cx(LABEL_CLASSES, className)} {...rest}>
{children}
</div>;
}
function DropdownMenuGroup({ className, children, ...rest }) {
return <div role="group" className={className} {...rest}>
{children}
</div>;
}
function DropdownMenuSeparator({
className,
...rest
}) {
return <div
role="separator"
aria-orientation="horizontal"
className={cx(SEPARATOR_CLASSES, className)}
{...rest}
/>;
}
export { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuGroup, DropdownMenuSeparator };
export default DropdownMenu; # Dropdown Menu Destructive
A menu containing destructive actions, styled with the restrained semantic destructive token and quarantined below a separator — clearly dangerous, never neon.
## Usage
```tsx
import DropdownMenu, {
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
} from "./dropdown-menu-destructive";
<DropdownMenu>
<DropdownMenuTrigger>Repository</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>Edit repository</DropdownMenuItem>
<DropdownMenuItem>Archive repository</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem destructive onSelect={(e) => confirmDelete(e)}>
Delete repository
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
```
## JavaScript
A `code.jsx` build is provided for projects that ship plain JSX. It exposes the same API and behavior as `code.tsx` — only the TypeScript types are removed.
```jsx
import DropdownMenu, {
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
} from "./dropdown-menu-destructive";
<DropdownMenu>
<DropdownMenuTrigger>Repository</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>Edit repository</DropdownMenuItem>
<DropdownMenuItem>Archive repository</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem destructive onSelect={(e) => confirmDelete(e)}>
Delete repository
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
```
## Props
### `<DropdownMenu>`
| Name | Type | Default | Description |
|---|---|---|---|
| `open` | `boolean` | — | Open state (controlled). |
| `defaultOpen` | `boolean` | `false` | Initial open state (uncontrolled). |
| `onOpenChange` | `(open: boolean) => void` | — | Called whenever the menu requests to open or close. |
| `placement` | `"bottom-start" \| "bottom-end" \| "top-start" \| "top-end"` | `"bottom-start"` | Preferred placement; flips to stay in the viewport. |
| `className` | `string` | — | Extra classes on the relative wrapper. |
| `children` | `ReactNode` | — | `DropdownMenuTrigger` + `DropdownMenuContent`. |
### `<DropdownMenuTrigger>`
| Name | Type | Default | Description |
|---|---|---|---|
| `className` | `string` | — | Extra classes on the button. |
| `children` | `ReactNode` | — | Visible trigger label (a chevron is rendered after it). |
A real `<button type="button">` with `aria-haspopup="menu"` + `aria-expanded`; every native button attribute (`disabled`, `aria-label`, …) is forwarded.
### `<DropdownMenuContent>`
| Name | Type | Default | Description |
|---|---|---|---|
| `className` | `string` | — | Extra classes on the `role="menu"` panel. |
| `children` | `ReactNode` | — | Items, labels, groups, and separators. |
Rendered only while open. Labelled by the trigger via `aria-labelledby`; pass `aria-label` to override.
### `<DropdownMenuItem>`
| Name | Type | Default | Description |
|---|---|---|---|
| `icon` | `ReactNode` | — | Meaningful leading icon (rendered aria-hidden). |
| `shortcut` | `string` | — | Informational shortcut at the trailing edge (aria-hidden; exposed via `aria-keyshortcuts`). |
| `destructive` | `boolean` | `false` | Destructive styling via the semantic destructive token. |
| `disabled` | `boolean` | `false` | Native disabled: skipped by arrow keys, out of the tab order, not activatable. |
| `closeOnSelect` | `boolean` | `true` | Whether activating the item closes the menu. |
| `onSelect` | `(event) => void` | — | Called on activation before the menu closes; `event.preventDefault()` keeps the menu open. |
| `children` | `ReactNode` | — | Visible item label. |
### `<DropdownMenuLabel>`
| Name | Type | Default | Description |
|---|---|---|---|
| `className` | `string` | — | Extra classes on the label. |
| `children` | `ReactNode` | — | Section heading text. |
Non-interactive. Give it an `id` and point the group's `aria-labelledby` at it when labelling a `DropdownMenuGroup`.
### `<DropdownMenuGroup>`
| Name | Type | Default | Description |
|---|---|---|---|
| `className` | `string` | — | Extra classes on the group. |
| `children` | `ReactNode` | — | Grouped items. |
Renders `role="group"`; forward `aria-labelledby` to associate it with its `DropdownMenuLabel`.
### `<DropdownMenuSeparator>`
| Name | Type | Default | Description |
|---|---|---|---|
| `className` | `string` | — | Extra classes on the separator. |
A `role="separator"` horizontal rule. Not focusable, not announced as an item.
## Composition
Dropdown Menu is a compound component. Seven primitives compose the pattern:
```tsx
<DropdownMenu>
<DropdownMenuTrigger>Actions</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>Project</DropdownMenuLabel>
<DropdownMenuGroup>
<DropdownMenuItem>Edit</DropdownMenuItem>
<DropdownMenuItem>Duplicate</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem destructive>Delete</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
```
- `DropdownMenu` — the root. Owns the open state (controlled via `open` + `onOpenChange`, or uncontrolled via `defaultOpen`), the trigger/content id wiring, the placement preference, and outside-pointer closing. Renders a `relative inline-flex` wrapper the menu panel anchors to.
- `DropdownMenuTrigger` — a real `<button type="button">` with `aria-haspopup="menu"` + `aria-expanded`. Click toggles; ArrowDown opens with the first item focused, ArrowUp with the last. The trailing chevron rotates while open.
- `DropdownMenuContent` — the `role="menu"` panel, labelled by the trigger. Rendered only while open; measures itself before paint and flips placement to stay in the viewport.
- `DropdownMenuItem` — one action: a real `<button>` with `role="menuitem"`. Optional `icon`, `shortcut`, `destructive`, `disabled`, `closeOnSelect`, and `onSelect` props.
- `DropdownMenuLabel` — a non-interactive section heading (uppercase, tracked, smaller type).
- `DropdownMenuGroup` — a `role="group"` wrapper; associate it with its label via `aria-labelledby`.
- `DropdownMenuSeparator` — a `role="separator"` horizontal rule between groups.
Destructive is the `destructive` prop on `<DropdownMenuItem>` — it swaps the item to `--ds-color-destructive` text on a `--ds-color-destructive-soft` hover/focus surface. Quarantine destructive actions below a `<DropdownMenuSeparator>` so they are visually and spatially separated from ordinary actions.
## Menu Behavior
The root `<DropdownMenu>` owns the open state. Both modes are supported:
- **Controlled** — pass `open` + `onOpenChange`; the parent owns the state.
- **Uncontrolled** — pass `defaultOpen`; the component owns the state.
Opening moves focus into the menu: the first item, or the last item when the trigger was invoked with ArrowUp. Activating an item runs its `onSelect` and then closes the menu (set `closeOnSelect={false}` or call `event.preventDefault()` in `onSelect` to keep it open). Closing — via selection, Escape, the trigger, or a pointer down outside — returns focus to the trigger, except on Tab, where focus is allowed to move forward naturally.
The panel opens relative to its trigger at the requested `placement` (`bottom-start` by default) and measures itself in a layout effect before paint: if the preferred side would leave the viewport, it flips to the other side (bottom ↔ top, start ↔ end). The panel also caps its own height (`min(20rem, 100vh - 2rem)` with internal scrolling) and width (`100vw - 1.5rem`), so menus never routinely overflow the viewport. No positioning library is involved.
For irreversible actions, keep the menu open and confirm first: call `event.preventDefault()` in `onSelect` (or set `closeOnSelect={false}`) and open a confirmation dialog instead of acting immediately.
## Keyboard Interaction
| Key | Behavior |
|---|---|
| `Enter` / `Space` (trigger) | Open the menu, focus the first item |
| `ArrowDown` (trigger) | Open the menu, focus the first item |
| `ArrowUp` (trigger) | Open the menu, focus the last item |
| `ArrowDown` / `ArrowUp` (menu) | Move focus to the next / previous enabled item, wrapping at the ends |
| `Home` / `End` (menu) | Focus the first / last enabled item |
| `Enter` / `Space` (menu) | Activate the focused item (native button behavior) |
| `Escape` | Close the menu and return focus to the trigger |
| `Tab` | Close the menu and move focus forward naturally |
The trigger and items are native `<button>` elements, so Enter/Space activation follows normal browser behavior. Disabled items use the native `disabled` attribute: they are skipped by arrow-key navigation, removed from the tab order, and cannot be activated.
## Accessibility
The structure follows the WAI-ARIA menu button pattern.
- The trigger is a native `<button>` with `aria-haspopup="menu"`, `aria-expanded`, and `aria-controls` pointing at the open panel.
- The panel is `role="menu"` labelled by its trigger (`aria-labelledby`); items are `role="menuitem"` on real `<button>` elements — no `div` click handlers.
- Focus is real DOM focus: opening moves focus into the menu, closing returns it to the trigger, and focus is never left on an unmounted element.
- Disabled items carry the native `disabled` attribute, which assistive technology announces as unavailable.
- `DropdownMenuSeparator` uses `role="separator"`; icons are `aria-hidden` decoration and shortcuts are exposed via `aria-keyshortcuts`, so accessible names stay clean.
Destructive state is communicated by the action's wording and position (below a separator, last in the menu) in addition to color — never by color alone. A disabled destructive item (`disabled` + `destructive`) keeps both cues: muted opacity and the destructive tint.
## States
- **Trigger (idle)** — bordered surface button with a muted chevron; hover shifts to a subtle surface.
- **Trigger (open)** — `aria-expanded="true"`; keeps the hover surface and rotates the chevron 180°.
- **Item (idle)** — foreground text on the elevated menu surface.
- **Item (hover / focus)** — `--ds-color-surface-hover` background; keyboard focus additionally shows the `--ds-color-focus-ring` outline inside the item bounds.
- **Item (disabled)** — native `disabled`: 50% opacity, no pointer events, skipped by arrow keys, out of the tab order.
- **Panel** — `--ds-color-surface-elevated` with a 1px `--ds-color-border` and the restrained `--ds-shadow-md`, per the Dropdown/Popover token rules (radius-md, subtle border, body-sm type).
- **Destructive item** — `--ds-color-destructive` text (a softened red that meets contrast in both themes, not neon), `--ds-color-destructive-soft` hover/focus surface; the wording ("Delete…", "Remove…") carries the meaning, color supports it.
## Responsive Behavior
The menu panel caps its width at `100vw - 1.5rem` and its height at `min(20rem, 100vh - 2rem)` with internal scrolling, so it stays inside the viewport at every width from 375px up without shrinking the trigger. Placement flips (bottom ↔ top, start ↔ end) keep the panel attached to its trigger near viewport edges. Long item labels truncate within the panel rather than forcing horizontal page overflow; the trigger label truncates within its own `max-w-full` bounds. The trigger keeps the shared 36px (h-9) control height — a comfortable touch target — at every breakpoint.
## Styling
Built with React, Tailwind CSS, and DevSnips design tokens. The component consumes the `--ds-*` semantic tokens via arbitrary values (for example `bg-[var(--ds-color-surface-elevated)]`). Define the tokens once in your theme — no component-specific CSS file is required.
## Design Tokens
See [React/DESIGN_TOKENS.md](../../../DESIGN_TOKENS.md) for the authoritative token specification. This dropdown-menu variant uses the semantic color, radius, shadow, typography, and motion tokens.
## Notes
Use at most one destructive cluster per menu, always at the bottom. If everything in the menu is destructive, the destructive styling loses its signal — prefer a dedicated confirmation flow. 567 lines UTF-8 · LF · Spaces: 2
Continue browsing