Skip to content

Commit 9bf6ce5

Browse files
authored
Virtua (#361)
* add: virtua * fix: Virtualizer items render issue inside of ScrollArea
1 parent b75bdb3 commit 9bf6ce5

File tree

4 files changed

+105
-65
lines changed

4 files changed

+105
-65
lines changed

frontend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@
120120
"tailwindcss-animate": "^1.0.7",
121121
"use-count-up": "^3.0.1",
122122
"vaul": "^1.1.2",
123+
"virtua": "^0.39.3",
123124
"workbox-window": "^7.3.0",
124125
"zod": "3.24.1",
125126
"zustand": "^5.0.3",

frontend/src/modules/ui/combobox.tsx

Lines changed: 49 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
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';
43
import { useFormContext } from 'react-hook-form';
4+
import { useTranslation } from 'react-i18next';
5+
import { Virtualizer } from 'virtua';
56
import { useBreakpoints } from '~/hooks/use-breakpoints';
67
import { useMeasure } from '~/hooks/use-measure';
78
import { AvatarWrap } from '~/modules/common/avatar-wrap';
9+
import ContentPlaceholder from '~/modules/common/content-placeholder';
810
import { Button } from '~/modules/ui/button';
911
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '~/modules/ui/command';
1012
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';
1214

1315
interface ComboBoxOption {
1416
value: string;
@@ -37,14 +39,17 @@ const Combobox: React.FC<ComboboxProps> = ({
3739
contentWidthMatchInput,
3840
disabled,
3941
}) => {
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);
4146

4247
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);
4651

47-
const [searchValue, setSearchValue] = React.useState('');
52+
const excludeAvatarWrapFields = ['timezone', 'country'];
4853

4954
const handleSelect = (newResult: string) => {
5055
const result = options.find((o) => o.label === newResult);
@@ -75,51 +80,59 @@ const Combobox: React.FC<ComboboxProps> = ({
7580
>
7681
{selectedOption ? (
7782
<div className="flex items-center truncate gap-2">
78-
{name !== 'timezone' && name !== 'country' && (
83+
{!excludeAvatarWrapFields.includes(name) && (
7984
<AvatarWrap className="h-6 w-6 text-xs shrink-0" id={selectedOption.value} name={name} url={selectedOption.url} />
8085
)}
81-
{renderOption && selectedOption ? renderOption(selectedOption) : <span className="truncate">{selectedOption.label}</span>}
86+
{renderOption?.(selectedOption) ?? <span className="truncate">{selectedOption.label}</span>}
8287
</div>
8388
) : (
84-
<span className="truncate">{placeholder || ''}</span>
89+
<span className="truncate">{placeholder || t('common:select')}</span>
8590
)}
8691
<ChevronDown className={`ml-2 h-4 w-4 shrink-0 opacity-50 transition-transform ${open ? '-rotate-90' : 'rotate-0'}`} />
8792
</Button>
8893
</PopoverTrigger>
8994

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">
9196
<Command>
9297
{!isMobile && (
9398
<CommandInput
9499
value={searchValue}
95-
onValueChange={(searchValue) => {
96-
setSearchValue(searchValue);
97-
}}
100+
onValueChange={setSearchValue}
98101
clearValue={setSearchValue}
99-
placeholder={searchPlaceholder || ''}
102+
placeholder={searchPlaceholder || t('common:search')}
100103
/>
101104
)}
102105

103106
<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>
123136
</CommandList>
124137
</Command>
125138
</PopoverContent>

frontend/src/modules/ui/scroll-area.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,15 +26,16 @@ const ScrollArea = React.forwardRef<
2626
VariantProps<typeof scrollbarVariants> &
2727
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> & {
2828
viewPortRef?: React.Ref<HTMLDivElement>;
29+
viewPortClassName?: string;
2930
}
30-
>(({ className, children, id, size, viewPortRef, ...props }, ref) => (
31+
>(({ className, children, id, size, viewPortRef, viewPortClassName, ...props }, ref) => (
3132
<ScrollAreaPrimitive.Root ref={ref} className={cn('relative overflow-auto', className)} {...props}>
3233
<ScrollAreaPrimitive.Viewport
3334
id={`${id}-viewport`}
3435
// to prevent warning on autoscroll set from Pragmatic DnD
35-
style={{ overflowY: 'scroll' }}
36+
style={{ overflowY: 'scroll', display: 'flex', flexDirection: 'column', height: '100%' }}
3637
ref={viewPortRef}
37-
className="h-full w-full [&>div]:!block rounded-[inherit]"
38+
className={cn('h-full w-full [&>div]:!block rounded-[inherit]', viewPortClassName)}
3839
>
3940
{children}
4041
</ScrollAreaPrimitive.Viewport>

pnpm-lock.yaml

Lines changed: 51 additions & 26 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)