|
1 |
| -import { Check, ChevronDown } from 'lucide-react'; |
2 |
| -import * as React from 'react'; |
3 |
| -import { useEffect } from 'react'; |
| 1 | +import { Check, ChevronDown, Search } from 'lucide-react'; |
| 2 | +import { useEffect, useRef, useState } from 'react'; |
4 | 3 | import { useFormContext } from 'react-hook-form';
|
| 4 | +import { useTranslation } from 'react-i18next'; |
| 5 | +import { Virtualizer } from 'virtua'; |
5 | 6 | import { useBreakpoints } from '~/hooks/use-breakpoints';
|
6 | 7 | import { useMeasure } from '~/hooks/use-measure';
|
7 | 8 | import { AvatarWrap } from '~/modules/common/avatar-wrap';
|
| 9 | +import ContentPlaceholder from '~/modules/common/content-placeholder'; |
8 | 10 | import { Button } from '~/modules/ui/button';
|
9 | 11 | import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '~/modules/ui/command';
|
10 | 12 | import { Popover, PopoverContent, PopoverTrigger } from '~/modules/ui/popover';
|
11 |
| -import { ScrollArea } from '~/modules/ui/scroll-area'; |
| 13 | +import { ScrollArea, ScrollBar } from '~/modules/ui/scroll-area'; |
12 | 14 |
|
13 | 15 | interface ComboBoxOption {
|
14 | 16 | value: string;
|
@@ -37,14 +39,17 @@ const Combobox: React.FC<ComboboxProps> = ({
|
37 | 39 | contentWidthMatchInput,
|
38 | 40 | disabled,
|
39 | 41 | }) => {
|
40 |
| - const formValue = useFormContext?.()?.getValues(name); |
| 42 | + const { t } = useTranslation(); |
| 43 | + const formValue = useFormContext()?.getValues(name); |
| 44 | + const isMobile = useBreakpoints('max', 'sm'); |
| 45 | + const scrollViewportRef = useRef<HTMLDivElement | null>(null); |
41 | 46 |
|
42 | 47 | const { ref, bounds } = useMeasure<HTMLButtonElement>();
|
43 |
| - const isMobile = useBreakpoints('max', 'sm'); |
44 |
| - const [open, setOpen] = React.useState(false); |
45 |
| - const [selectedOption, setSelectedOption] = React.useState<ComboBoxOption | null>(options.find((o) => o.value === formValue) || null); |
| 48 | + const [open, setOpen] = useState(false); |
| 49 | + const [searchValue, setSearchValue] = useState(''); |
| 50 | + const [selectedOption, setSelectedOption] = useState<ComboBoxOption | null>(options.find((o) => o.value === formValue) || null); |
46 | 51 |
|
47 |
| - const [searchValue, setSearchValue] = React.useState(''); |
| 52 | + const excludeAvatarWrapFields = ['timezone', 'country']; |
48 | 53 |
|
49 | 54 | const handleSelect = (newResult: string) => {
|
50 | 55 | const result = options.find((o) => o.label === newResult);
|
@@ -75,51 +80,59 @@ const Combobox: React.FC<ComboboxProps> = ({
|
75 | 80 | >
|
76 | 81 | {selectedOption ? (
|
77 | 82 | <div className="flex items-center truncate gap-2">
|
78 |
| - {name !== 'timezone' && name !== 'country' && ( |
| 83 | + {!excludeAvatarWrapFields.includes(name) && ( |
79 | 84 | <AvatarWrap className="h-6 w-6 text-xs shrink-0" id={selectedOption.value} name={name} url={selectedOption.url} />
|
80 | 85 | )}
|
81 |
| - {renderOption && selectedOption ? renderOption(selectedOption) : <span className="truncate">{selectedOption.label}</span>} |
| 86 | + {renderOption?.(selectedOption) ?? <span className="truncate">{selectedOption.label}</span>} |
82 | 87 | </div>
|
83 | 88 | ) : (
|
84 |
| - <span className="truncate">{placeholder || ''}</span> |
| 89 | + <span className="truncate">{placeholder || t('common:select')}</span> |
85 | 90 | )}
|
86 | 91 | <ChevronDown className={`ml-2 h-4 w-4 shrink-0 opacity-50 transition-transform ${open ? '-rotate-90' : 'rotate-0'}`} />
|
87 | 92 | </Button>
|
88 | 93 | </PopoverTrigger>
|
89 | 94 |
|
90 |
| - <PopoverContent align="start" style={{ width: `${contentWidthMatchInput ? `${bounds.left + bounds.right + 2}px` : '100%'}` }} className={'p-0'}> |
| 95 | + <PopoverContent align="start" style={{ width: contentWidthMatchInput ? `${bounds.width}px` : '100%' }} className="p-0"> |
91 | 96 | <Command>
|
92 | 97 | {!isMobile && (
|
93 | 98 | <CommandInput
|
94 | 99 | value={searchValue}
|
95 |
| - onValueChange={(searchValue) => { |
96 |
| - setSearchValue(searchValue); |
97 |
| - }} |
| 100 | + onValueChange={setSearchValue} |
98 | 101 | clearValue={setSearchValue}
|
99 |
| - placeholder={searchPlaceholder || ''} |
| 102 | + placeholder={searchPlaceholder || t('common:search')} |
100 | 103 | />
|
101 | 104 | )}
|
102 | 105 |
|
103 | 106 | <CommandList>
|
104 |
| - <CommandEmpty>No option found</CommandEmpty> |
105 |
| - <ScrollArea className="max-h-[30vh] overflow-y-auto"> |
106 |
| - <CommandGroup> |
107 |
| - {options.map((option) => ( |
108 |
| - <CommandItem |
109 |
| - key={option.value?.trim().toLowerCase() + Math.floor(Math.random() * 1000)} |
110 |
| - value={option.label} |
111 |
| - onSelect={handleSelect} |
112 |
| - className="group rounded-md flex justify-between items-center w-full leading-normal" |
113 |
| - > |
114 |
| - <div className="flex items-center gap-2"> |
115 |
| - {name !== 'timezone' && name !== 'country' && <AvatarWrap id={option.value} name={name} url={option.url} />} |
116 |
| - {renderOption ? renderOption(option) : <> {option.label}</>} |
117 |
| - </div> |
118 |
| - <Check size={16} className={`text-success ${formValue !== option.value && 'invisible'}`} /> |
119 |
| - </CommandItem> |
120 |
| - ))} |
121 |
| - </CommandGroup> |
122 |
| - </ScrollArea> |
| 107 | + <CommandEmpty> |
| 108 | + <ContentPlaceholder Icon={Search} title={t('common:no_resource_found', { resource: t(`common:${name}`).toLowerCase() })} /> |
| 109 | + </CommandEmpty> |
| 110 | + |
| 111 | + <CommandGroup> |
| 112 | + {/* To avoid conflicts between ScrollArea and Virtualizer, do not set a max-h value on ScrollArea. |
| 113 | + As this will cause all list elements to render at once in Virtualizer*/} |
| 114 | + <ScrollArea className="h-[30vh]" viewPortRef={scrollViewportRef}> |
| 115 | + <ScrollBar /> |
| 116 | + <Virtualizer as="ul" item="li" scrollRef={scrollViewportRef} overscan={1}> |
| 117 | + {options |
| 118 | + .filter(({ label }) => label.toLowerCase().includes(searchValue.toLowerCase())) |
| 119 | + .map((option) => ( |
| 120 | + <CommandItem |
| 121 | + key={option.value} |
| 122 | + value={option.label} |
| 123 | + onSelect={handleSelect} |
| 124 | + className="group rounded-md flex justify-between items-center w-full leading-normal" |
| 125 | + > |
| 126 | + <div className="flex items-center gap-2"> |
| 127 | + {!excludeAvatarWrapFields.includes(name) && <AvatarWrap id={option.value} name={name} url={option.url} />} |
| 128 | + {renderOption?.(option) ?? option.label} |
| 129 | + </div> |
| 130 | + <Check size={16} className={`text-success ${formValue !== option.value && 'invisible'}`} /> |
| 131 | + </CommandItem> |
| 132 | + ))} |
| 133 | + </Virtualizer> |
| 134 | + </ScrollArea> |
| 135 | + </CommandGroup> |
123 | 136 | </CommandList>
|
124 | 137 | </Command>
|
125 | 138 | </PopoverContent>
|
|
0 commit comments