From 448dfe778f434c6b18b23730323a11f7541fe0b5 Mon Sep 17 00:00:00 2001 From: Nate Kelley Date: Mon, 24 Feb 2025 11:38:28 -0700 Subject: [PATCH] dropdown v1 --- .../ui/dropdown/Dropdown.stories.tsx | 211 ++++++++++++++++++ web/src/components/ui/dropdown/Dropdown.tsx | 74 +++++- .../components/ui/dropdown/DropdownBase.tsx | 8 +- web/src/styles/tailwind.css | 2 +- 4 files changed, 288 insertions(+), 7 deletions(-) create mode 100644 web/src/components/ui/dropdown/Dropdown.stories.tsx diff --git a/web/src/components/ui/dropdown/Dropdown.stories.tsx b/web/src/components/ui/dropdown/Dropdown.stories.tsx new file mode 100644 index 000000000..09589d627 --- /dev/null +++ b/web/src/components/ui/dropdown/Dropdown.stories.tsx @@ -0,0 +1,211 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { Dropdown } from './Dropdown'; +import { Button } from '../buttons/Button'; +const meta: Meta = { + title: 'Base/Dropdown', + component: Dropdown, + parameters: { + layout: 'centered' + }, + tags: ['autodocs'] +}; + +export default meta; +type Story = StoryObj; + +// 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: + } +}; + +// 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: + } +}; + +// 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: + } +}; + +// 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: + } +}; + +// 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: + } +}; + +// 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: + } +}; + +// 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: + } +}; diff --git a/web/src/components/ui/dropdown/Dropdown.tsx b/web/src/components/ui/dropdown/Dropdown.tsx index 038494edd..a9f3ff4dd 100644 --- a/web/src/components/ui/dropdown/Dropdown.tsx +++ b/web/src/components/ui/dropdown/Dropdown.tsx @@ -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 = ({ + items = [], + selectType = 'default', + menuLabel, + minWidth = 200, + maxWidth, + closeOnSelect = true, + onSelect, + children, + ...props +}) => { + const renderItems = (items: DropdownItem[]) => { + return items.map((item) => { + if (item.items) { + return ( + + + {item.icon && {item.icon}} + {item.label} + + + {renderItems(item.items)} + + + ); + } + + return ( + { + if (item.onClick) item.onClick(); + if (onSelect) onSelect(item); + }}> + {item.icon && {item.icon}} + {item.label} + {item.shortcut && ( + {item.shortcut} + )} + + ); + }); + }; + + return ( + + {children} + + {menuLabel && {menuLabel}} + {menuLabel && } + {renderItems(items)} + + + ); +}; diff --git a/web/src/components/ui/dropdown/DropdownBase.tsx b/web/src/components/ui/dropdown/DropdownBase.tsx index 1db2421f1..3747ddf9a 100644 --- a/web/src/components/ui/dropdown/DropdownBase.tsx +++ b/web/src/components/ui/dropdown/DropdownBase.tsx @@ -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< diff --git a/web/src/styles/tailwind.css b/web/src/styles/tailwind.css index d56984837..932e2d53e 100644 --- a/web/src/styles/tailwind.css +++ b/web/src/styles/tailwind.css @@ -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';