From e068c27b8cae2c1a52fd37ea58523c8d99e50770 Mon Sep 17 00:00:00 2001 From: Nate Kelley Date: Tue, 25 Feb 2025 14:59:21 -0700 Subject: [PATCH] dropdown with a search --- web/src/components/ui/buttons/Button.tsx | 4 +- .../ui/dropdown/Dropdown.stories.tsx | 37 ++++++++ web/src/components/ui/dropdown/Dropdown.tsx | 92 ++++++++++++------- .../components/ui/dropdown/DropdownBase.tsx | 10 +- web/src/components/ui/inputs/Input.tsx | 2 +- 5 files changed, 107 insertions(+), 38 deletions(-) diff --git a/web/src/components/ui/buttons/Button.tsx b/web/src/components/ui/buttons/Button.tsx index 70d540ca3..7ffb7dbe3 100644 --- a/web/src/components/ui/buttons/Button.tsx +++ b/web/src/components/ui/buttons/Button.tsx @@ -145,8 +145,8 @@ export const Button = React.forwardRef( return ( = { title: 'Base/Dropdown', @@ -297,8 +298,44 @@ export const WithSearchHeader: Story = { secondaryLabel: 'View starred items', onClick: () => console.log('Favorites clicked'), icon: + }, + { type: 'divider' }, + { + id: '4', + label: 'Logout', + + onClick: () => console.log('Logout clicked') + }, + { + id: '5', + label: 'Invite User', + onClick: () => console.log('Invite User clicked') } ], children: } }; + +// 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: + } +}; diff --git a/web/src/components/ui/dropdown/Dropdown.tsx b/web/src/components/ui/dropdown/Dropdown.tsx index 509c2716f..9659a74f2 100644 --- a/web/src/components/ui/dropdown/Dropdown.tsx +++ b/web/src/components/ui/dropdown/Dropdown.tsx @@ -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 = 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 ( {children} - + {menuHeader && ( <> = React.memo( )} - {filteredItems.length > 0 ? ( - filteredItems.map((item, index) => ( - - )) - ) : ( - - {emptyStateText} - - )} +
+ {hasShownItem ? ( + filteredItems.map((item, index) => ( + + )) + ) : ( + + {emptyStateText} + + )} +
); @@ -182,7 +193,8 @@ const DropdownItem: React.FC< closeOnSelect, onSelect, selectType = false, - secondaryLabel + secondaryLabel, + truncate }) => { const onClickItem = useMemoizedFn((e: React.MouseEvent) => { 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 && {index}} {icon && !loading && {icon}} {loading && } -
- {label} +
+ {label} {secondaryLabel && {secondaryLabel}}
{shortcut && {shortcut}} @@ -235,7 +241,11 @@ const DropdownItem: React.FC< } return ( - + {content} ); @@ -314,12 +324,28 @@ const DropdownMenuHeaderSearch: React.FC = ({ return (
onChange(e.target.value)} + onChange={(e) => { + e.stopPropagation(); + e.preventDefault(); + onChange(e.target.value); + }} + onKeyDown={(e) => { + e.stopPropagation(); + }} /> + {/* +
+ +
*/}
); }; diff --git a/web/src/components/ui/dropdown/DropdownBase.tsx b/web/src/components/ui/dropdown/DropdownBase.tsx index b0e2f2fbf..05724b230 100644 --- a/web/src/components/ui/dropdown/DropdownBase.tsx +++ b/web/src/components/ui/dropdown/DropdownBase.tsx @@ -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) => ( { @@ -152,7 +154,11 @@ const DropdownMenuSeparator = React.forwardRef< >(({ className, ...props }, ref) => ( )); diff --git a/web/src/components/ui/inputs/Input.tsx b/web/src/components/ui/inputs/Input.tsx index 25e1beb19..a5256729c 100644 --- a/web/src/components/ui/inputs/Input.tsx +++ b/web/src/components/ui/inputs/Input.tsx @@ -49,7 +49,7 @@ export const Input = React.forwardRef( return (