dropdown v1

This commit is contained in:
Nate Kelley 2025-02-24 11:38:28 -07:00
parent 07f4e32d6d
commit 448dfe778f
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
4 changed files with 288 additions and 7 deletions

View File

@ -0,0 +1,211 @@
import type { Meta, StoryObj } from '@storybook/react';
import { Dropdown } from './Dropdown';
import { Button } from '../buttons/Button';
const meta: Meta<typeof Dropdown> = {
title: 'Base/Dropdown',
component: Dropdown,
parameters: {
layout: 'centered'
},
tags: ['autodocs']
};
export default meta;
type Story = StoryObj<typeof Dropdown>;
// Basic example with simple items
export const Basic: Story = {
args: {
items: [
{
id: '1',
label: 'Profile',
onClick: () => console.log('Profile clicked')
},
{
id: '2',
label: 'Settings',
onClick: () => console.log('Settings clicked'),
shortcut: '⌘S'
},
{
id: '3',
label: 'Logout',
onClick: () => console.log('Logout clicked'),
shortcut: '⌘L'
}
],
children: <Button>Open Menu</Button>
}
};
// Example with icons and shortcuts
export const WithIconsAndShortcuts: Story = {
args: {
menuLabel: 'Menu Options',
items: [
{
id: '1',
label: 'Profile',
icon: '👤',
shortcut: '⌘P',
onClick: () => console.log('Profile clicked')
},
{
id: '2',
label: 'Settings',
icon: '⚙️',
shortcut: '⌘S',
onClick: () => console.log('Settings clicked')
},
{
id: '3',
label: 'Logout',
icon: '🚪',
shortcut: '⌘L',
onClick: () => console.log('Logout clicked')
}
],
children: <Button>Menu with Icons</Button>
}
};
// Example with nested items
export const WithNestedItems: Story = {
args: {
menuLabel: 'Nested Menu',
items: [
{
id: '1',
label: 'Main Options',
items: [
{
id: '1-1',
label: 'Option 1',
onClick: () => console.log('Option 1 clicked')
},
{
id: '1-2',
label: 'Option 2',
onClick: () => console.log('Option 2 clicked')
}
]
},
{
id: '2',
label: 'More Options',
items: [
{
id: '2-1',
label: 'Sub Option 1',
onClick: () => console.log('Sub Option 1 clicked')
},
{
id: '2-2',
label: 'Sub Option 2',
onClick: () => console.log('Sub Option 2 clicked')
}
]
}
],
children: <Button>Nested Menu</Button>
}
};
// Example with disabled items
export const WithDisabledItems: Story = {
args: {
items: [
{
id: '1',
label: 'Available Option',
onClick: () => console.log('Available clicked')
},
{
id: '2',
label: 'Disabled Option',
disabled: true,
onClick: () => console.log('Should not be called')
},
{
id: '3',
label: 'Another Available',
onClick: () => console.log('Another clicked')
}
],
children: <Button>Menu with Disabled Items</Button>
}
};
// Example with custom widths
export const CustomWidth: Story = {
args: {
menuLabel: 'Custom Width Menu',
minWidth: 300,
maxWidth: 400,
items: [
{
id: '1',
label: 'This is a very long menu item that might need wrapping',
onClick: () => console.log('Long item clicked')
},
{
id: '2',
label: 'Short item',
onClick: () => console.log('Short item clicked')
}
],
children: <Button>Wide Menu</Button>
}
};
// Example with loading state
export const WithLoadingItems: Story = {
args: {
items: [
{
id: '1',
label: 'Normal Item',
onClick: () => console.log('Normal clicked')
},
{
id: '2',
label: 'Loading Item',
loading: true,
onClick: () => console.log('Loading clicked')
},
{
id: '3',
label: 'Another Normal',
onClick: () => console.log('Another clicked')
}
],
children: <Button>Menu with Loading</Button>
}
};
// Example with selection
export const WithSelection: Story = {
args: {
selectType: 'single',
items: [
{
id: '1',
label: 'Option 1',
selected: true,
onClick: () => console.log('Option 1 clicked')
},
{
id: '2',
label: 'Option 2',
onClick: () => console.log('Option 2 clicked')
},
{
id: '3',
label: 'Option 3',
onClick: () => console.log('Option 3 clicked')
}
],
children: <Button>Selection Menu</Button>
}
};

View File

@ -1,5 +1,16 @@
import { DropdownMenuProps } from '@radix-ui/react-dropdown-menu';
import React from 'react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
DropdownMenuSub,
DropdownMenuPortal
} from './DropdownBase';
import { useMemoizedFn } from 'ahooks';
export interface DropdownItem {
label: React.ReactNode;
@ -23,6 +34,65 @@ export interface ButtonDropdownProps extends DropdownMenuProps {
maxWidth?: number;
closeOnSelect?: boolean;
onSelect?: (item: DropdownItem) => void;
onClose?: () => void;
onOpen?: () => void;
}
export const Dropdown: React.FC<ButtonDropdownProps> = ({
items = [],
selectType = 'default',
menuLabel,
minWidth = 200,
maxWidth,
closeOnSelect = true,
onSelect,
children,
...props
}) => {
const renderItems = (items: DropdownItem[]) => {
return items.map((item) => {
if (item.items) {
return (
<DropdownMenuSub key={item.id}>
<DropdownMenuTrigger>
{item.icon && <span className="mr-2">{item.icon}</span>}
<span>{item.label}</span>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent>{renderItems(item.items)}</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuSub>
);
}
return (
<DropdownMenuItem
key={item.id}
disabled={item.disabled}
onClick={() => {
if (item.onClick) item.onClick();
if (onSelect) onSelect(item);
}}>
{item.icon && <span className="mr-2">{item.icon}</span>}
<span>{item.label}</span>
{item.shortcut && (
<span className="ml-auto text-xs tracking-widest opacity-60">{item.shortcut}</span>
)}
</DropdownMenuItem>
);
});
};
return (
<DropdownMenu {...props}>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent
style={{
minWidth: minWidth,
maxWidth: maxWidth
}}>
{menuLabel && <DropdownMenuLabel>{menuLabel}</DropdownMenuLabel>}
{menuLabel && <DropdownMenuSeparator />}
{renderItems(items)}
</DropdownMenuContent>
</DropdownMenu>
);
};

View File

@ -66,7 +66,7 @@ const DropdownMenuContent = React.forwardRef<
ref={ref}
sideOffset={sideOffset}
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md',
'bg-background text-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md',
className
)}
{...props}
@ -84,7 +84,7 @@ const DropdownMenuItem = React.forwardRef<
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm transition-colors outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
'focus:bg-item-hover focus:text-foreground relative flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm transition-colors outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
inset && 'pl-8',
className
)}
@ -100,7 +100,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center rounded-sm py-1.5 pr-2 pl-8 text-sm transition-colors outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
'focus:bg-accent focus:text-accent-foreground relative flex cursor-pointer items-center rounded-sm py-1.5 pr-2 pl-8 text-sm transition-colors outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
checked={checked}
@ -124,7 +124,7 @@ const DropdownMenuRadioItem = React.forwardRef<
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center rounded-sm py-1.5 pr-2 pl-8 text-sm transition-colors outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
'focus:bg-accent focus:text-accent-foreground relative flex cursor-pointer items-center rounded-sm py-1.5 pr-2 pl-8 text-sm transition-colors outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}>

View File

@ -75,7 +75,7 @@
--color-track-background: hsl(0 0% 89.8%);
--color-icon-color: var(--color-gray-dark);
--color-ring: var(--color-foreground-hover);
--color-ring: var(--color-item-hover);
}
@import './tailwindAnimations.css';