下拉菜单
一个可定制的下拉菜单组件,用于显示上下文相关的操作选项
安装
npx
npx hb-design-cli add DropdownMenu
预览
基本下拉菜单
基本下拉菜单
带图标的下拉菜单
带图标的下拉菜单
带分组和标签的下拉菜单
带分组和标签的下拉菜单
带有偏移量的下拉菜单
带有偏移量的下拉菜单
禁用项
禁用项的下拉菜单
表单选择器
表单选择器的下拉菜单
上下文菜单
点击右侧按钮打开上下文菜单
文件内容
上下文菜单的下拉菜单
组件源码
DropdownMenu 组件源码
import React, { createContext, useContext, useEffect, forwardRef, useState } from 'react';
import { cn } from '@/lib/utils';
import { ChevronDown, ChevronUp } from 'lucide-react';
interface DropdownMenuItemData {
value: string;
icons?: React.ReactNode[];
disabled?: boolean;
onClick?: () => void;
className?: string;
}
interface DropdownMenuContextType {
open: boolean;
onOpenChange: (open: boolean) => void;
}
const DropdownMenuContext = createContext<DropdownMenuContextType | null>(null);
function useDropdownMenu() {
const context = useContext(DropdownMenuContext);
if (context === null) {
throw new Error('useDropdownMenu must be used within a DropdownMenu');
}
return context;
}
interface DropdownMenuProps {
children: React.ReactNode;
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
interface DropdownMenuTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'default' | 'secondary';
size?: 'sm' | 'md' | 'lg';
className?: string;
children: React.ReactNode;
}
interface DropdownMenuContentProps extends React.HTMLAttributes<HTMLDivElement> {
className?: string;
children?: React.ReactNode;
align?: 'start' | 'center' | 'end';
side?: 'bottom' | 'top';
sideOffset?: number;
items?: DropdownMenuItemData[];
}
interface DropdownMenuItemProps extends React.LiHTMLAttributes<HTMLLIElement> {
className?: string;
children: React.ReactNode;
disabled?: boolean;
}
interface DropdownMenuSeparatorProps extends React.HTMLAttributes<HTMLDivElement> {
className?: string;
}
interface DropdownMenuLabelProps extends React.HTMLAttributes<HTMLDivElement> {
className?: string;
children: React.ReactNode;
}
interface DropdownMenuGroupProps extends React.HTMLAttributes<HTMLDivElement> {
className?: string;
children: React.ReactNode;
}
export const DropdownMenu = forwardRef<HTMLDivElement, DropdownMenuProps>(
({ children, defaultOpen = false, open: openProp, onOpenChange: onOpenChangeProp }, ref) => {
const [localOpen, setLocalOpen] = useState(defaultOpen);
const open = openProp !== undefined ? openProp : localOpen;
const onOpenChange = onOpenChangeProp || setLocalOpen;
const close = () => onOpenChange(false);
useEffect(() => {
if (!open) return;
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement;
const menuElement = typeof ref === 'function' ? null : ref?.current;
if (menuElement && !menuElement.contains(target)) {
close();
}
};
document.addEventListener('click', handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside);
}, [open, close]);
useEffect(() => {
if (!open) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
close();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [open, close]);
return (
<div ref={ref} className="relative inline-block">
<DropdownMenuContext.Provider value={{ open, onOpenChange }}>
{children}
</DropdownMenuContext.Provider>
</div>
);
}
);
DropdownMenu.displayName = 'DropdownMenu';
export const DropdownMenuTrigger = forwardRef<HTMLButtonElement, DropdownMenuTriggerProps>(
({ className, variant = 'default', size = 'md', children, ...props }, ref) => {
const { open, onOpenChange } = useDropdownMenu();
const baseClasses = "inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50";
const variantClasses = {
default: "bg-black text-white hover:bg-gray-800",
secondary: "bg-gray-200 text-gray-800 hover:bg-gray-300"
};
const sizeClasses = {
sm: "h-8 px-3 text-sm",
md: "h-10 px-4 py-2",
lg: "h-12 px-6 text-lg"
};
const classes = cn(
baseClasses,
variantClasses[variant],
sizeClasses[size],
className
);
return (
<button
ref={ref}
className={classes}
onClick={(e: React.MouseEvent<HTMLButtonElement>) => onOpenChange(!open)}
{...props}
>
{children}
<span className="ml-2">
{open ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</span>
</button>
);
}
);
DropdownMenuTrigger.displayName = 'DropdownMenuTrigger';
export const DropdownMenuContent = forwardRef<HTMLDivElement, DropdownMenuContentProps>(
({ className, children, align = 'start', side = 'bottom', sideOffset = 4, items, ...props }, ref) => {
const { open } = useDropdownMenu();
if (!open) return null;
const alignClasses = {
start: "justify-start",
center: "justify-center",
end: "justify-end"
};
const sideClasses = {
bottom: "top-full",
top: "bottom-full"
};
const offsetClasses = sideOffset > 0 ?
(side === 'bottom' ? mt-sideOffset : mb-sideOffset) :
'';
const classes = cn(
"absolute left-0 right-0 z-50 w-max min-w-[160px] bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-md shadow-lg animate-in fade-in-80 slide-in-from-top-2",
alignClasses[align],
sideClasses[side],
offsetClasses,
className
);
const renderItems = () => {
if (!items || items.length === 0) return null;
return items.map((item, index) => (
<li
key={item.value}
className={cn(
"flex items-center px-4 py-2 text-sm transition-colors focus-visible:outline-none focus-visible:bg-gray-100 dark:focus-visible:bg-gray-800",
item.disabled ? "text-gray-500 dark:text-gray-400 cursor-not-allowed" : "text-gray-800 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800",
item.className
)}
onClick={() => {
if (!item.disabled && item.onClick) {
item.onClick();
}
}}
>
{item.icons && item.icons.length > 0 && (
<span className="flex mr-2">
{item.icons}
</span>
)}
{item.value}
</li>
));
};
return (
<div ref={ref} className={classes} {...props}>
<ul className="py-1">
{children || renderItems()}
</ul>
</div>
);
}
);
DropdownMenuContent.displayName = 'DropdownMenuContent';
export const DropdownMenuItem = forwardRef<HTMLLIElement, DropdownMenuItemProps>(
({ className, children, disabled = false, ...props }, ref) => {
const { onOpenChange } = useDropdownMenu();
const handleClick = (e: React.MouseEvent) => {
if (!disabled) {
onOpenChange(false);
if (props.onClick) {
props.onClick(e as React.MouseEvent<HTMLLIElement>);
}
}
};
const classes = cn(
"flex items-center px-4 py-2 text-sm transition-colors focus-visible:outline-none focus-visible:bg-gray-100 dark:focus-visible:bg-gray-800",
disabled ? "text-gray-500 dark:text-gray-400 cursor-not-allowed" : "text-gray-800 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800",
className
);
return (
<li ref={ref} className={classes} onClick={handleClick} {...props}>
{children}
</li>
);
}
);
DropdownMenuItem.displayName = 'DropdownMenuItem';
export const DropdownMenuSeparator = forwardRef<HTMLDivElement, DropdownMenuSeparatorProps>(
({ className, ...props }, ref) => {
return (
<div ref={ref} className={cn("h-px bg-gray-200 dark:bg-gray-800 my-1", className)} {...props} />
);
}
);
DropdownMenuSeparator.displayName = 'DropdownMenuSeparator';
export const DropdownMenuLabel = forwardRef<HTMLDivElement, DropdownMenuLabelProps>(
({ className, children, ...props }, ref) => {
return (
<div
ref={ref}
className={cn("px-4 py-2 text-xs font-semibold text-gray-500 dark:text-gray-400", className)}
{...props}
>
{children}
</div>
);
}
);
DropdownMenuLabel.displayName = 'DropdownMenuLabel';
export const DropdownMenuGroup = forwardRef<HTMLDivElement, DropdownMenuGroupProps>(
({ className, children, ...props }, ref) => {
return (
<div ref={ref} className={cn("space-y-1", className)} {...props}>
{children}
</div>
);
}
);
DropdownMenuGroup.displayName = 'DropdownMenuGroup';属性说明
| 属性名 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| variant | default | secondary | default | 下拉菜单触发器的样式变体 |
| size | sm | md | lg | md | 下拉菜单触发器的尺寸 |
| align | start | center | end | start | 下拉菜单内容的对齐方式 |
| side | bottom | top | bottom | 下拉菜单弹出的方向 |
| sideOffset | number | 4 | 下拉菜单与触发器的偏移量 |
| disabled | boolean | false | 是否禁用下拉菜单项 |
| defaultOpen | boolean | false | 是否默认打开下拉菜单 |
| open | boolean | undefined | 控制下拉菜单的打开状态(受控模式) |
| onOpenChange | function | undefined | 下拉菜单打开状态变化时的回调函数 |