mirror of https://github.com/buster-so/buster.git
dropdown with a search
This commit is contained in:
parent
2053e338df
commit
e068c27b8c
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
));
|
||||
|
|
|
@ -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}
|
||||
|
|
Loading…
Reference in New Issue