Skip to content

Commit 28fe121

Browse files
authored
🔧 fix: Ariakit Combobox Virtualization (danny-avila#5851)
Ariakit Combobox was not working well with several virtualization libraries as automated focus management was conflicting with scrolling/styling required of other virtualization methods. The entire strategy was replaced using experimental ariakit virtualization component `SelectRenderer` Performance of component was also improved as a result of latest ariakit lib changes
1 parent e402979 commit 28fe121

File tree

3 files changed

+115
-124
lines changed

3 files changed

+115
-124
lines changed

client/package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@
2828
},
2929
"homepage": "https://librechat.ai",
3030
"dependencies": {
31-
"@ariakit/react": "^0.4.11",
31+
"@ariakit/react": "^0.4.15",
32+
"@ariakit/react-core": "^0.4.15",
3233
"@codesandbox/sandpack-react": "^2.19.10",
3334
"@dicebear/collection": "^7.0.4",
3435
"@dicebear/core": "^7.0.4",

client/src/components/ui/ControlCombobox.tsx

+88-96
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1+
import { Search } from 'lucide-react';
12
import * as Ariakit from '@ariakit/react';
23
import { matchSorter } from 'match-sorter';
3-
import { AutoSizer, List } from 'react-virtualized';
4-
import { startTransition, useMemo, useState, useEffect, useRef, memo } from 'react';
5-
import { cn } from '~/utils';
4+
import { useMemo, useState, useRef, memo, useEffect } from 'react';
5+
import { SelectRenderer } from '@ariakit/react-core/select/select-renderer';
66
import type { OptionWithIcon } from '~/common';
7-
import { Search } from 'lucide-react';
7+
import { cn } from '~/utils';
88

99
interface ControlComboboxProps {
1010
selectedValue: string;
@@ -35,11 +35,33 @@ function ControlCombobox({
3535
const buttonRef = useRef<HTMLButtonElement>(null);
3636
const [buttonWidth, setButtonWidth] = useState<number | null>(null);
3737

38+
const getItem = (option: OptionWithIcon) => ({
39+
id: `item-${option.value}`,
40+
value: option.value as string | undefined,
41+
label: option.label,
42+
icon: option.icon,
43+
});
44+
45+
const combobox = Ariakit.useComboboxStore({
46+
defaultItems: items.map(getItem),
47+
resetValueOnHide: true,
48+
value: searchValue,
49+
setValue: setSearchValue,
50+
});
51+
52+
const select = Ariakit.useSelectStore({
53+
combobox,
54+
defaultItems: items.map(getItem),
55+
value: selectedValue,
56+
setValue,
57+
});
58+
3859
const matches = useMemo(() => {
39-
return matchSorter(items, searchValue, {
60+
const filteredItems = matchSorter(items, searchValue, {
4061
keys: ['value', 'label'],
4162
baseSort: (a, b) => (a.index < b.index ? -1 : 1),
4263
});
64+
return filteredItems.map(getItem);
4365
}, [searchValue, items]);
4466

4567
useEffect(() => {
@@ -48,104 +70,74 @@ function ControlCombobox({
4870
}
4971
}, [isCollapsed]);
5072

51-
const rowRenderer = ({
52-
index,
53-
key,
54-
style,
55-
}: {
56-
index: number;
57-
key: string;
58-
style: React.CSSProperties;
59-
}) => {
60-
const item = matches[index];
61-
return (
62-
<Ariakit.SelectItem
63-
key={key}
64-
value={`${item.value ?? ''}`}
65-
aria-label={`${item.label ?? item.value ?? ''}`}
73+
return (
74+
<div className="flex w-full items-center justify-center px-1">
75+
<Ariakit.SelectLabel store={select} className="sr-only">
76+
{ariaLabel}
77+
</Ariakit.SelectLabel>
78+
<Ariakit.Select
79+
ref={buttonRef}
80+
store={select}
6681
className={cn(
67-
'flex cursor-pointer items-center px-3 text-sm',
82+
'flex items-center justify-center gap-2 rounded-full bg-surface-secondary',
6883
'text-text-primary hover:bg-surface-tertiary',
69-
'data-[active-item]:bg-surface-tertiary',
84+
'border border-border-light',
85+
isCollapsed ? 'h-10 w-10' : 'h-10 w-full rounded-md px-3 py-2 text-sm',
7086
)}
71-
render={<Ariakit.ComboboxItem />}
72-
style={style}
7387
>
74-
{item.icon != null && (
75-
<div className="assistant-item mr-2 flex h-5 w-5 items-center justify-center overflow-hidden rounded-full">
76-
{item.icon}
88+
{SelectIcon != null && (
89+
<div className="assistant-item flex h-5 w-5 items-center justify-center overflow-hidden rounded-full">
90+
{SelectIcon}
7791
</div>
7892
)}
79-
<span className="flex-grow truncate text-left">{item.label}</span>
80-
</Ariakit.SelectItem>
81-
);
82-
};
83-
84-
return (
85-
<div className="flex w-full items-center justify-center px-1">
86-
<Ariakit.ComboboxProvider
87-
resetValueOnHide
88-
setValue={(value) => {
89-
startTransition(() => {
90-
setSearchValue(value);
91-
});
92-
}}
93+
{!isCollapsed && (
94+
<span className="flex-grow truncate text-left">{displayValue ?? selectPlaceholder}</span>
95+
)}
96+
</Ariakit.Select>
97+
<Ariakit.SelectPopover
98+
store={select}
99+
gutter={4}
100+
portal
101+
className="z-50 overflow-hidden rounded-md border border-border-light bg-surface-secondary shadow-lg"
102+
style={{ width: isCollapsed ? '300px' : (buttonWidth ?? '300px') }}
93103
>
94-
<Ariakit.SelectProvider value={selectedValue} setValue={setValue}>
95-
<Ariakit.SelectLabel className="sr-only">{ariaLabel}</Ariakit.SelectLabel>
96-
<Ariakit.Select
97-
ref={buttonRef}
98-
className={cn(
99-
'flex items-center justify-center gap-2 rounded-full bg-surface-secondary',
100-
'text-text-primary hover:bg-surface-tertiary',
101-
'border border-border-light',
102-
isCollapsed ? 'h-10 w-10' : 'h-10 w-full rounded-md px-3 py-2 text-sm',
103-
)}
104-
>
105-
{SelectIcon != null && (
106-
<div className="assistant-item flex h-5 w-5 items-center justify-center overflow-hidden rounded-full">
107-
{SelectIcon}
108-
</div>
109-
)}
110-
{!isCollapsed && (
111-
<span className="flex-grow truncate text-left">
112-
{displayValue ?? selectPlaceholder}
113-
</span>
114-
)}
115-
</Ariakit.Select>
116-
<Ariakit.SelectPopover
117-
gutter={4}
118-
portal
119-
className="z-50 overflow-hidden rounded-md border border-border-light bg-surface-secondary shadow-lg"
120-
style={{ width: isCollapsed ? '300px' : buttonWidth ?? '300px' }}
121-
>
122-
<div className="p-2">
123-
<div className="relative">
124-
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-text-primary" />
125-
<Ariakit.Combobox
126-
autoSelect
127-
placeholder={searchPlaceholder}
128-
className="w-full rounded-md border border-border-light bg-surface-tertiary py-2 pl-9 pr-3 text-sm text-text-primary focus:outline-none"
129-
/>
130-
</div>
131-
</div>
132-
<div className="max-h-[50vh]">
133-
<AutoSizer disableHeight>
134-
{({ width }) => (
135-
<List
136-
width={width}
137-
height={Math.min(matches.length * ROW_HEIGHT, 300)}
138-
rowCount={matches.length}
139-
rowHeight={ROW_HEIGHT}
140-
rowRenderer={rowRenderer}
141-
overscanRowCount={5}
142-
/>
143-
)}
144-
</AutoSizer>
145-
</div>
146-
</Ariakit.SelectPopover>
147-
</Ariakit.SelectProvider>
148-
</Ariakit.ComboboxProvider>
104+
<div className="p-2">
105+
<div className="relative">
106+
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-text-primary" />
107+
<Ariakit.Combobox
108+
store={combobox}
109+
autoSelect
110+
placeholder={searchPlaceholder}
111+
className="w-full rounded-md border border-border-light bg-surface-tertiary py-2 pl-9 pr-3 text-sm text-text-primary focus:outline-none"
112+
/>
113+
</div>
114+
</div>
115+
<div className="max-h-[300px] overflow-auto">
116+
<Ariakit.ComboboxList store={combobox}>
117+
<SelectRenderer store={select} items={matches} itemSize={ROW_HEIGHT} overscan={5}>
118+
{({ value, icon, label, ...item }) => (
119+
<Ariakit.ComboboxItem
120+
key={item.id}
121+
{...item}
122+
className={cn(
123+
'flex w-full cursor-pointer items-center px-3 text-sm',
124+
'text-text-primary hover:bg-surface-tertiary',
125+
'data-[active-item]:bg-surface-tertiary',
126+
)}
127+
render={<Ariakit.SelectItem value={value} />}
128+
>
129+
{icon != null && (
130+
<div className="assistant-item mr-2 flex h-5 w-5 items-center justify-center overflow-hidden rounded-full">
131+
{icon}
132+
</div>
133+
)}
134+
<span className="flex-grow truncate text-left">{label}</span>
135+
</Ariakit.ComboboxItem>
136+
)}
137+
</SelectRenderer>
138+
</Ariakit.ComboboxList>
139+
</div>
140+
</Ariakit.SelectPopover>
149141
</div>
150142
);
151143
}

package-lock.json

+25-27
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)