dropdown with a search

This commit is contained in:
Nate Kelley 2025-02-25 14:59:21 -07:00
parent 2053e338df
commit e068c27b8c
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
5 changed files with 107 additions and 38 deletions

View File

@ -145,8 +145,8 @@ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
return (
<Comp
className={cn(
buttonVariants({ variant, size, iconButton, rounding, block, className }),
onClick && 'cursor-pointer'
'cursor-pointer',
buttonVariants({ variant, size, iconButton, rounding, block, className })
)}
onClick={onClick}
ref={ref}

View File

@ -2,6 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react';
import { Dropdown } from './Dropdown';
import { Button } from '../buttons/Button';
import { PaintRoller, Star, Storage } from '../icons';
import { faker } from '@faker-js/faker';
const meta: Meta<typeof Dropdown> = {
title: 'Base/Dropdown',
@ -297,8 +298,44 @@ export const WithSearchHeader: Story = {
secondaryLabel: 'View starred items',
onClick: () => console.log('Favorites clicked'),
icon: <Star />
},
{ type: 'divider' },
{
id: '4',
label: 'Logout',
onClick: () => console.log('Logout clicked')
},
{
id: '5',
label: 'Invite User',
onClick: () => console.log('Invite User clicked')
}
],
children: <Button>Searchable Menu</Button>
}
};
// Example with long text to test truncation
export const WithLongText: Story = {
args: {
menuHeader: {
placeholder: 'Search items...'
},
items: [
...Array.from({ length: 100 }).map(() => {
const label = faker.commerce.product();
const secondaryLabel = faker.commerce.productDescription();
return {
id: faker.string.uuid(),
label,
secondaryLabel,
searchLabel: label + ' ' + secondaryLabel,
onClick: () => console.log('Long text clicked'),
truncate: true
};
})
],
children: <Button>Long Text Menu</Button>
}
};

View File

@ -22,6 +22,7 @@ import { useDebounceSearch } from '@/hooks';
export interface DropdownItem {
label: React.ReactNode | string;
truncate?: boolean;
searchLabel?: string; // Used for filtering
secondaryLabel?: string;
id: string;
@ -83,20 +84,28 @@ export const Dropdown: React.FC<DropdownProps> = React.memo(
const { filteredItems, searchText, handleSearchChange } = useDebounceSearch({
items,
searchPredicate: (item, searchText) => {
if ((item as DropdownItem).id && (item as DropdownItem).searchLabel) {
return ((item as DropdownItem).searchLabel || '')
?.toLowerCase()
.includes(searchText.toLowerCase());
if ((item as DropdownItem).id) {
const _item = item as DropdownItem;
const searchContent =
_item.searchLabel || (typeof _item.label === 'string' ? _item.label : '');
return searchContent?.toLowerCase().includes(searchText.toLowerCase());
}
return true;
},
debounceTime: 50
});
const hasShownItem = useMemo(() => {
return filteredItems.length > 0 && filteredItems.some((item) => (item as DropdownItem).id);
}, [filteredItems]);
return (
<DropdownMenu open={open} defaultOpen={open} onOpenChange={onOpenChange} {...props}>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent className={cn('w-56', contentClassName)} align={align} side={side}>
<DropdownMenuContent
className={cn('max-w-72 min-w-44', contentClassName)}
align={align}
side={side}>
{menuHeader && (
<>
<DropdownMenuHeaderSelector
@ -108,22 +117,24 @@ export const Dropdown: React.FC<DropdownProps> = React.memo(
</>
)}
{filteredItems.length > 0 ? (
filteredItems.map((item, index) => (
<DropdownItemSelector
item={item}
index={index}
selectType={selectType}
onSelect={onSelect}
closeOnSelect={closeOnSelect}
key={dropdownItemKey(item, index)}
/>
))
) : (
<DropdownMenuItem disabled className="text-gray-light text-center">
{emptyStateText}
</DropdownMenuItem>
)}
<div className="max-h-[300px] overflow-y-auto">
{hasShownItem ? (
filteredItems.map((item, index) => (
<DropdownItemSelector
item={item}
index={index}
selectType={selectType}
onSelect={onSelect}
closeOnSelect={closeOnSelect}
key={dropdownItemKey(item, index)}
/>
))
) : (
<DropdownMenuItem disabled className="text-gray-light text-center">
{emptyStateText}
</DropdownMenuItem>
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
);
@ -182,7 +193,8 @@ const DropdownItem: React.FC<
closeOnSelect,
onSelect,
selectType = false,
secondaryLabel
secondaryLabel,
truncate
}) => {
const onClickItem = useMemoizedFn((e: React.MouseEvent<HTMLDivElement>) => {
if (onClick) onClick();
@ -191,19 +203,13 @@ const DropdownItem: React.FC<
const isSubItem = items && items.length > 0;
const Wrapper = useMemo(() => {
if (isSubItem) return DropdownSubMenuWrapper;
if (selectType) return DropdownMenuCheckboxItem;
return DropdownMenuItem;
}, [isSubItem, selectType]);
const content = (
<>
{showIndex && <span className="text-gray-light">{index}</span>}
{icon && !loading && <span className="text-icon-color">{icon}</span>}
{loading && <CircleSpinnerLoader size={9} />}
<div className="flex flex-col gap-y-1">
{label}
<div className={cn('flex flex-col gap-y-1', truncate && 'overflow-hidden')}>
<span className={cn(truncate && 'truncate')}>{label}</span>
{secondaryLabel && <span className="text-gray-light text-xxs">{secondaryLabel}</span>}
</div>
{shortcut && <DropdownMenuShortcut>{shortcut}</DropdownMenuShortcut>}
@ -235,7 +241,11 @@ const DropdownItem: React.FC<
}
return (
<DropdownMenuItem disabled={disabled} onClick={onClickItem} closeOnSelect={closeOnSelect}>
<DropdownMenuItem
truncate={truncate}
disabled={disabled}
onClick={onClickItem}
closeOnSelect={closeOnSelect}>
{content}
</DropdownMenuItem>
);
@ -314,12 +324,28 @@ const DropdownMenuHeaderSearch: React.FC<DropdownMenuHeaderSearchProps> = ({
return (
<div className="flex items-center gap-x-2">
<Input
variant={'ghost'}
autoFocus
variant={'ghost'}
placeholder={placeholder}
value={text}
onChange={(e) => onChange(e.target.value)}
onChange={(e) => {
e.stopPropagation();
e.preventDefault();
onChange(e.target.value);
}}
onKeyDown={(e) => {
e.stopPropagation();
}}
/>
{/*
<div className="flex pr-1 opacity-20 hover:opacity-100">
<Button
className="cursor-pointer"
onClick={() => onChange('')}
prefix={<CircleXmark />}
variant={'link'}
size={'small'}></Button>
</div> */}
</div>
);
};

View File

@ -77,13 +77,15 @@ const DropdownMenuItem = React.forwardRef<
inset?: boolean;
closeOnSelect?: boolean;
selectType?: string;
truncate?: boolean;
}
>(({ className, closeOnSelect = true, onClick, inset, selectType, ...props }, ref) => (
>(({ className, closeOnSelect = true, onClick, inset, selectType, truncate, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'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',
truncate && 'overflow-hidden',
className
)}
onClick={(e) => {
@ -152,7 +154,11 @@ const DropdownMenuSeparator = React.forwardRef<
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn('bg-border -mx-1 my-1 h-[0.5px]', className)}
className={cn(
'bg-border dropdown-separator -mx-1 my-1 h-[0.5px]',
'[&.dropdown-separator:has(+.dropdown-separator)]:hidden [&.dropdown-separator:last-child]:hidden',
className
)}
{...props}
/>
));

View File

@ -49,7 +49,7 @@ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
return (
<input
type={'type'}
type={type}
className={cn(inputVariants({ size, variant }), className)}
ref={ref}
onKeyDown={handleKeyDown}