haob-design

下拉菜单

一个可定制的下拉菜单组件,用于显示上下文相关的操作选项

安装

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';

属性说明

属性名类型默认值说明
variantdefault | secondarydefault下拉菜单触发器的样式变体
sizesm | md | lgmd下拉菜单触发器的尺寸
alignstart | center | endstart下拉菜单内容的对齐方式
sidebottom | topbottom下拉菜单弹出的方向
sideOffsetnumber4下拉菜单与触发器的偏移量
disabledbooleanfalse是否禁用下拉菜单项
defaultOpenbooleanfalse是否默认打开下拉菜单
openbooleanundefined控制下拉菜单的打开状态(受控模式)
onOpenChangefunctionundefined下拉菜单打开状态变化时的回调函数