dropdown selection done

This commit is contained in:
Nate Kelley 2025-02-25 16:47:49 -07:00
parent bbbbcaa1b8
commit a4fd95b7fd
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
5 changed files with 202 additions and 48 deletions

View File

@ -86,7 +86,7 @@ export const DisabledChecked: Story = {
// Example with event handler // Example with event handler
export const WithOnChange: Story = { export const WithOnChange: Story = {
args: { args: {
onCheckedChange: (checked: boolean) => console.log('Checked:', checked) onCheckedChange: (checked: boolean) => alert(`Checked: ${checked}`)
} }
}; };

View File

@ -35,20 +35,20 @@ export const Basic: Story = {
{ {
id: '1', id: '1',
label: 'Profile', label: 'Profile',
onClick: () => console.log('Profile clicked'), onClick: () => alert('Profile clicked'),
loading: false, loading: false,
icon: <PaintRoller /> icon: <PaintRoller />
}, },
{ {
id: '2', id: '2',
label: 'Settings', label: 'Settings',
onClick: () => console.log('Settings clicked'), onClick: () => alert('Settings clicked'),
shortcut: '⌘S' shortcut: '⌘S'
}, },
{ {
id: '3', id: '3',
label: 'Logout', label: 'Logout',
onClick: () => console.log('Logout clicked'), onClick: () => alert('Logout clicked'),
items: [ items: [
{ {
id: '3-1', id: '3-1',
@ -75,21 +75,21 @@ export const WithIconsAndShortcuts: Story = {
label: 'Profile', label: 'Profile',
icon: '👤', icon: '👤',
shortcut: '⌘P', shortcut: '⌘P',
onClick: () => console.log('Profile clicked') onClick: () => alert('Profile clicked')
}, },
{ {
id: '2', id: '2',
label: 'Settings', label: 'Settings',
icon: '⚙️', icon: '⚙️',
shortcut: '⌘S', shortcut: '⌘S',
onClick: () => console.log('Settings clicked') onClick: () => alert('Settings clicked')
}, },
{ {
id: '3', id: '3',
label: 'Logout', label: 'Logout',
icon: '🚪', icon: '🚪',
shortcut: '⌘L', shortcut: '⌘L',
onClick: () => console.log('Logout clicked') onClick: () => alert('Logout clicked')
} }
], ],
children: <Button>Menu with Icons</Button> children: <Button>Menu with Icons</Button>
@ -108,12 +108,12 @@ export const WithNestedItems: Story = {
{ {
id: '1-1', id: '1-1',
label: 'Option 1', label: 'Option 1',
onClick: () => console.log('Option 1 clicked') onClick: () => alert('Option 1 clicked')
}, },
{ {
id: '1-2', id: '1-2',
label: 'Option 2', label: 'Option 2',
onClick: () => console.log('Option 2 clicked') onClick: () => alert('Option 2 clicked')
} }
] ]
}, },
@ -124,12 +124,12 @@ export const WithNestedItems: Story = {
{ {
id: '2-1', id: '2-1',
label: 'Sub Option 1', label: 'Sub Option 1',
onClick: () => console.log('Sub Option 1 clicked') onClick: () => alert('Sub Option 1 clicked')
}, },
{ {
id: '2-2', id: '2-2',
label: 'Sub Option 2', label: 'Sub Option 2',
onClick: () => console.log('Sub Option 2 clicked') onClick: () => alert('Sub Option 2 clicked')
} }
] ]
} }
@ -145,18 +145,18 @@ export const WithDisabledItems: Story = {
{ {
id: '1', id: '1',
label: 'Available Option', label: 'Available Option',
onClick: () => console.log('Available clicked') onClick: () => alert('Available clicked')
}, },
{ {
id: '2', id: '2',
label: 'Disabled Option', label: 'Disabled Option',
disabled: true, disabled: true,
onClick: () => console.log('Should not be called') onClick: () => alert('Should not be called')
}, },
{ {
id: '3', id: '3',
label: 'Another Available', label: 'Another Available',
onClick: () => console.log('Another clicked') onClick: () => alert('Another clicked')
} }
], ],
children: <Button>Menu with Disabled Items</Button> children: <Button>Menu with Disabled Items</Button>
@ -173,12 +173,12 @@ export const CustomWidth: Story = {
{ {
id: '1', id: '1',
label: 'This is a very long menu item that might need wrapping', label: 'This is a very long menu item that might need wrapping',
onClick: () => console.log('Long item clicked') onClick: () => alert('Long item clicked')
}, },
{ {
id: '2', id: '2',
label: 'Short item', label: 'Short item',
onClick: () => console.log('Short item clicked') onClick: () => alert('Short item clicked')
} }
], ],
children: <Button>Wide Menu</Button> children: <Button>Wide Menu</Button>
@ -192,29 +192,29 @@ export const WithLoadingItems: Story = {
{ {
id: '1', id: '1',
label: 'Normal Item', label: 'Normal Item',
onClick: () => console.log('Normal clicked') onClick: () => alert('Normal clicked')
}, },
{ {
id: '2', id: '2',
label: 'Loading Item', label: 'Loading Item',
loading: true, loading: true,
onClick: () => console.log('Loading clicked') onClick: () => alert('Loading clicked')
}, },
{ {
id: '3', id: '3',
label: 'Another Normal', label: 'Another Normal',
onClick: () => console.log('Another clicked') onClick: () => alert('Another clicked')
}, },
{ type: 'divider' }, { type: 'divider' },
{ {
id: '4', id: '4',
label: 'Option 4', label: 'Option 4',
onClick: () => console.log('Option 4 clicked') onClick: () => alert('Option 4 clicked')
}, },
{ {
id: '5', id: '5',
label: 'Option 5', label: 'Option 5',
onClick: () => console.log('Option 5 clicked') onClick: () => alert('Option 5 clicked')
} }
], ],
children: <Button>Menu with Loading</Button> children: <Button>Menu with Loading</Button>
@ -230,17 +230,17 @@ export const WithSelectionSingle: Story = {
id: '1', id: '1',
label: 'Option 1', label: 'Option 1',
selected: false, selected: false,
onClick: () => console.log('Option 1 clicked') onClick: () => alert('Option 1 clicked')
}, },
{ {
id: '2', id: '2',
label: 'Option 2', label: 'Option 2',
onClick: () => console.log('Option 2 clicked') onClick: () => alert('Option 2 clicked')
}, },
{ {
id: '3', id: '3',
label: 'Option 3 - Selected', label: 'Option 3 - Selected',
onClick: () => console.log('Option 3 clicked'), onClick: () => alert('Option 3 clicked'),
selected: true selected: true
} }
], ],
@ -257,32 +257,32 @@ export const WithSelectionMultiple: Story = {
id: '1', id: '1',
label: 'Option 1', label: 'Option 1',
selected: selectedIds.has('1'), selected: selectedIds.has('1'),
onClick: () => console.log('Option 1 clicked') onClick: () => alert('Option 1 clicked')
}, },
{ {
id: '2', id: '2',
label: 'Option 2', label: 'Option 2',
selected: selectedIds.has('2'), selected: selectedIds.has('2'),
onClick: () => console.log('Option 2 clicked') onClick: () => alert('Option 2 clicked')
}, },
{ {
id: '3', id: '3',
label: 'Option 3', label: 'Option 3',
selected: selectedIds.has('3'), selected: selectedIds.has('3'),
onClick: () => console.log('Option 3 clicked') onClick: () => alert('Option 3 clicked')
}, },
{ type: 'divider' as const }, { type: 'divider' as const },
{ {
id: '4', id: '4',
label: 'Option 4', label: 'Option 4',
selected: selectedIds.has('4'), selected: selectedIds.has('4'),
onClick: () => console.log('Option 4 clicked') onClick: () => alert('Option 4 clicked')
}, },
{ {
id: '5', id: '5',
label: 'Option 5', label: 'Option 5',
selected: selectedIds.has('5'), selected: selectedIds.has('5'),
onClick: () => console.log('Option 5 clicked') onClick: () => alert('Option 5 clicked')
} }
]; ];
@ -319,14 +319,14 @@ export const WithSecondaryLabel: Story = {
id: '1', id: '1',
label: 'Profile Settings', label: 'Profile Settings',
secondaryLabel: 'User preferences', secondaryLabel: 'User preferences',
onClick: () => console.log('Profile clicked'), onClick: () => alert('Profile clicked'),
icon: <PaintRoller /> icon: <PaintRoller />
}, },
{ {
id: '2', id: '2',
label: 'Storage', label: 'Storage',
secondaryLabel: '45GB used', secondaryLabel: '45GB used',
onClick: () => console.log('Storage clicked'), onClick: () => alert('Storage clicked'),
icon: <Storage /> icon: <Storage />
}, },
{ type: 'divider' }, { type: 'divider' },
@ -334,7 +334,7 @@ export const WithSecondaryLabel: Story = {
id: '3', id: '3',
label: 'Subscription', label: 'Subscription',
secondaryLabel: 'Pro Plan', secondaryLabel: 'Pro Plan',
onClick: () => console.log('Subscription clicked'), onClick: () => alert('Subscription clicked'),
icon: <Star /> icon: <Star />
} }
], ],
@ -354,7 +354,7 @@ export const WithSearchHeader: Story = {
label: 'Profile Settings', label: 'Profile Settings',
searchLabel: 'profile settings user preferences account', searchLabel: 'profile settings user preferences account',
secondaryLabel: 'User preferences', secondaryLabel: 'User preferences',
onClick: () => console.log('Profile clicked'), onClick: () => alert('Profile clicked'),
icon: <PaintRoller /> icon: <PaintRoller />
}, },
{ {
@ -362,7 +362,7 @@ export const WithSearchHeader: Story = {
label: 'Storage Options', label: 'Storage Options',
searchLabel: 'storage disk space memory', searchLabel: 'storage disk space memory',
secondaryLabel: 'Manage storage space', secondaryLabel: 'Manage storage space',
onClick: () => console.log('Storage clicked'), onClick: () => alert('Storage clicked'),
icon: <Storage /> icon: <Storage />
}, },
{ {
@ -370,7 +370,7 @@ export const WithSearchHeader: Story = {
label: 'Favorites', label: 'Favorites',
searchLabel: 'favorites starred items bookmarks', searchLabel: 'favorites starred items bookmarks',
secondaryLabel: 'View starred items', secondaryLabel: 'View starred items',
onClick: () => console.log('Favorites clicked'), onClick: () => alert('Favorites clicked'),
icon: <Star /> icon: <Star />
}, },
{ type: 'divider' }, { type: 'divider' },
@ -378,12 +378,12 @@ export const WithSearchHeader: Story = {
id: '4', id: '4',
label: 'Logout', label: 'Logout',
onClick: () => console.log('Logout clicked') onClick: () => alert('Logout clicked')
}, },
{ {
id: '5', id: '5',
label: 'Invite User', label: 'Invite User',
onClick: () => console.log('Invite User clicked') onClick: () => alert('Invite User clicked')
} }
], ],
children: <Button>Searchable Menu</Button> children: <Button>Searchable Menu</Button>
@ -405,7 +405,7 @@ export const WithLongText: Story = {
label, label,
secondaryLabel, secondaryLabel,
searchLabel: label + ' ' + secondaryLabel, searchLabel: label + ' ' + secondaryLabel,
onClick: () => console.log('Long text clicked'), onClick: () => alert('Long text clicked'),
truncate: true truncate: true
}; };
}) })
@ -413,3 +413,105 @@ export const WithLongText: Story = {
children: <Button>Long Text Menu</Button> children: <Button>Long Text Menu</Button>
} }
}; };
// Example with links
export const WithLinks: Story = {
args: {
menuHeader: 'Navigation Links',
items: [
{
id: '1',
label: 'Documentation',
link: '/docs',
icon: <Storage />,
onClick: () => alert('Documentation clicked')
},
{
id: '2',
label: 'GitHub Repository',
link: 'https://github.com/example/repo',
icon: <Star />,
secondaryLabel: 'External Link'
},
{ type: 'divider' },
{
id: '3',
label: 'Settings Page',
link: '/settings',
icon: <PaintRoller />
},
{
id: '4',
label: 'Help Center',
link: '/help',
secondaryLabel: 'Get Support'
}
],
children: <Button>Menu with Links</Button>
}
};
// Interactive example with links and multiple selection
export const WithLinksAndMultipleSelection: Story = {
render: () => {
const [selectedIds, setSelectedIds] = React.useState<Set<string>>(new Set(['2']));
const items: DropdownItems = [
{
id: '1',
label: 'Documentation Home',
link: '/docs',
selected: selectedIds.has('1'),
icon: <Storage />,
secondaryLabel: 'Main documentation'
},
{
id: '2',
label: 'API Reference',
link: '/docs/api',
selected: selectedIds.has('2'),
icon: <Star />,
secondaryLabel: 'API documentation'
},
{ type: 'divider' as const },
{
id: '3',
label: 'Tutorials',
link: '/docs/tutorials',
selected: selectedIds.has('3'),
icon: <PaintRoller />,
secondaryLabel: 'Learn step by step'
},
{
id: '4',
label: 'Examples',
link: '/docs/examples',
selected: selectedIds.has('4'),
secondaryLabel: 'Code examples'
}
];
const handleSelect = (itemId: string) => {
setSelectedIds((prev) => {
const newSet = new Set(prev);
if (newSet.has(itemId)) {
newSet.delete(itemId);
} else {
newSet.add(itemId);
}
return newSet;
});
};
return (
<Dropdown
open
selectType="multiple"
items={items}
menuHeader={{ placeholder: 'Search documentation...' }}
onSelect={handleSelect}
children={<Button>Documentation Sections</Button>}
/>
);
}
};

View File

@ -1,10 +1,11 @@
import { DropdownMenuLabel, DropdownMenuProps } from '@radix-ui/react-dropdown-menu'; import { DropdownMenuProps } from '@radix-ui/react-dropdown-menu';
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuGroup, //Do I need this? DropdownMenuGroup, //Do I need this?
DropdownMenuItem, DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal, DropdownMenuPortal,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuShortcut, DropdownMenuShortcut,
@ -13,7 +14,8 @@ import {
DropdownMenuSubTrigger, DropdownMenuSubTrigger,
DropdownMenuTrigger, DropdownMenuTrigger,
DropdownMenuCheckboxItemSingle, DropdownMenuCheckboxItemSingle,
DropdownMenuCheckboxItemMultiple DropdownMenuCheckboxItemMultiple,
DropdownMenuLink
} from './DropdownBase'; } from './DropdownBase';
import { CircleSpinnerLoader } from '../loaders/CircleSpinnerLoader'; import { CircleSpinnerLoader } from '../loaders/CircleSpinnerLoader';
import { useMemoizedFn } from 'ahooks'; import { useMemoizedFn } from 'ahooks';
@ -35,6 +37,8 @@ export interface DropdownItem {
loading?: boolean; loading?: boolean;
selected?: boolean; selected?: boolean;
items?: DropdownItems; items?: DropdownItems;
link?: string;
linkIcon?: 'arrow-right' | 'arrow-external' | 'caret-right';
} }
export interface DropdownDivider { export interface DropdownDivider {
@ -233,7 +237,9 @@ const DropdownItem: React.FC<
onSelect, onSelect,
selectType, selectType,
secondaryLabel, secondaryLabel,
truncate truncate,
link,
linkIcon
}) => { }) => {
const onClickItem = useMemoizedFn((e: React.MouseEvent<HTMLDivElement>) => { const onClickItem = useMemoizedFn((e: React.MouseEvent<HTMLDivElement>) => {
if (onClick) onClick(); if (onClick) onClick();
@ -252,6 +258,13 @@ const DropdownItem: React.FC<
{secondaryLabel && <span className="text-gray-light text-xxs">{secondaryLabel}</span>} {secondaryLabel && <span className="text-gray-light text-xxs">{secondaryLabel}</span>}
</div> </div>
{shortcut && <DropdownMenuShortcut>{shortcut}</DropdownMenuShortcut>} {shortcut && <DropdownMenuShortcut>{shortcut}</DropdownMenuShortcut>}
{link && (
<DropdownMenuLink
className="-mr-1 ml-auto opacity-0 group-hover:opacity-50 hover:opacity-100"
link={link}
linkIcon={linkIcon}
/>
)}
</> </>
); );

View File

@ -2,10 +2,19 @@
import * as React from 'react'; import * as React from 'react';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { Check3 as Check, ChevronRight } from '../icons/NucleoIconOutlined'; import {
ArrowRight,
ArrowUpRight,
CaretRight,
Check3 as Check,
ChevronRight
} from '../icons/NucleoIconOutlined';
import { cn } from '@/lib/classMerge'; import { cn } from '@/lib/classMerge';
import { Checkbox } from '../checkbox/Checkbox'; import { Checkbox } from '../checkbox/Checkbox';
import { Button } from '../buttons/Button';
import { useRouter } from 'next/router';
import Link from 'next/link';
const DropdownMenu = DropdownMenuPrimitive.Root; const DropdownMenu = DropdownMenuPrimitive.Root;
@ -83,6 +92,7 @@ const DropdownMenuItem = React.forwardRef<
'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]: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]:shrink-0',
inset && 'pl-8', inset && 'pl-8',
truncate && 'overflow-hidden', truncate && 'overflow-hidden',
'group',
className className
)} )}
onClick={(e) => { onClick={(e) => {
@ -100,7 +110,8 @@ DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const itemClass = cn( const itemClass = cn(
'focus:bg-item-hover focus:text-foreground', 'focus:bg-item-hover focus:text-foreground',
'relative flex cursor-pointer items-center rounded-sm py-1.5 text-sm transition-colors outline-none select-none', 'relative flex cursor-pointer items-center rounded-sm py-1.5 text-sm transition-colors outline-none select-none',
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50' 'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
'gap-1.5'
); );
const DropdownMenuCheckboxItemSingle = React.forwardRef< const DropdownMenuCheckboxItemSingle = React.forwardRef<
@ -180,7 +191,7 @@ const DropdownMenuLabel = React.forwardRef<
>(({ className, inset, ...props }, ref) => ( >(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label <DropdownMenuPrimitive.Label
ref={ref} ref={ref}
className={cn('px-2 py-1.5 text-sm', inset && 'pl-8', className)} className={cn('text-gray-dark px-2 py-1.5 text-sm', inset && 'pl-8', className)}
{...props} {...props}
/> />
)); ));
@ -209,6 +220,33 @@ const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTML
}; };
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut'; DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
const DropdownMenuLink: React.FC<{
className?: string;
link: string;
linkIcon?: 'arrow-right' | 'arrow-external' | 'caret-right';
}> = ({ className, link, linkIcon = 'arrow-right', ...props }) => {
const icon = React.useMemo(() => {
if (linkIcon === 'arrow-right') return <ArrowRight />;
if (linkIcon === 'arrow-external') return <ArrowUpRight />;
if (linkIcon === 'caret-right') return <CaretRight />;
}, [linkIcon]);
const isExternal = link?.startsWith('http');
return (
<Link className={className} href={link} target={isExternal ? '_blank' : '_self'}>
<Button
prefix={icon}
variant="ghost"
size="small"
rounding={'default'}
className={cn('text-gray-dark hover:bg-gray-dark/8')}
/>
</Link>
);
};
DropdownMenuLink.displayName = 'DropdownMenuLink';
export { export {
DropdownMenu, DropdownMenu,
DropdownMenuTrigger, DropdownMenuTrigger,
@ -223,5 +261,6 @@ export {
DropdownMenuSub, DropdownMenuSub,
DropdownMenuSubContent, DropdownMenuSubContent,
DropdownMenuSubTrigger, DropdownMenuSubTrigger,
DropdownMenuCheckboxItemMultiple DropdownMenuCheckboxItemMultiple,
DropdownMenuLink
}; };

View File

@ -50,7 +50,7 @@ export const Default: Story = {
args: { args: {
items: items, items: items,
open: true, open: true,
onSelect: (item) => console.log('Selected:', item.value) onSelect: (item) => alert(`Selected: ${item.value}`)
} }
}; };
@ -66,7 +66,7 @@ export const WithDisabledItems: Story = {
} }
], ],
open: true, open: true,
onSelect: (item) => console.log('Selected:', item) onSelect: (item) => alert(`Selected: ${item.value}`)
} }
}; };
@ -76,6 +76,6 @@ export const CustomStyling: Story = {
items: items, items: items,
open: true, open: true,
className: 'bg-slate-100 rounded-lg shadow-lg', className: 'bg-slate-100 rounded-lg shadow-lg',
onSelect: (item) => console.log('Selected:', item) onSelect: (item) => alert(`Selected: ${item.value}`)
} }
}; };