mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-09-22 08:12:00 +02:00
🔧 fix: Ariakit Combobox Virtualization (#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
This commit is contained in:
parent
e402979cc5
commit
28fe1218c5
3 changed files with 119 additions and 128 deletions
|
@ -28,7 +28,8 @@
|
|||
},
|
||||
"homepage": "https://librechat.ai",
|
||||
"dependencies": {
|
||||
"@ariakit/react": "^0.4.11",
|
||||
"@ariakit/react": "^0.4.15",
|
||||
"@ariakit/react-core": "^0.4.15",
|
||||
"@codesandbox/sandpack-react": "^2.19.10",
|
||||
"@dicebear/collection": "^7.0.4",
|
||||
"@dicebear/core": "^7.0.4",
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import { Search } from 'lucide-react';
|
||||
import * as Ariakit from '@ariakit/react';
|
||||
import { matchSorter } from 'match-sorter';
|
||||
import { AutoSizer, List } from 'react-virtualized';
|
||||
import { startTransition, useMemo, useState, useEffect, useRef, memo } from 'react';
|
||||
import { cn } from '~/utils';
|
||||
import { useMemo, useState, useRef, memo, useEffect } from 'react';
|
||||
import { SelectRenderer } from '@ariakit/react-core/select/select-renderer';
|
||||
import type { OptionWithIcon } from '~/common';
|
||||
import { Search } from 'lucide-react';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
interface ControlComboboxProps {
|
||||
selectedValue: string;
|
||||
|
@ -35,11 +35,33 @@ function ControlCombobox({
|
|||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const [buttonWidth, setButtonWidth] = useState<number | null>(null);
|
||||
|
||||
const getItem = (option: OptionWithIcon) => ({
|
||||
id: `item-${option.value}`,
|
||||
value: option.value as string | undefined,
|
||||
label: option.label,
|
||||
icon: option.icon,
|
||||
});
|
||||
|
||||
const combobox = Ariakit.useComboboxStore({
|
||||
defaultItems: items.map(getItem),
|
||||
resetValueOnHide: true,
|
||||
value: searchValue,
|
||||
setValue: setSearchValue,
|
||||
});
|
||||
|
||||
const select = Ariakit.useSelectStore({
|
||||
combobox,
|
||||
defaultItems: items.map(getItem),
|
||||
value: selectedValue,
|
||||
setValue,
|
||||
});
|
||||
|
||||
const matches = useMemo(() => {
|
||||
return matchSorter(items, searchValue, {
|
||||
const filteredItems = matchSorter(items, searchValue, {
|
||||
keys: ['value', 'label'],
|
||||
baseSort: (a, b) => (a.index < b.index ? -1 : 1),
|
||||
});
|
||||
return filteredItems.map(getItem);
|
||||
}, [searchValue, items]);
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -48,104 +70,74 @@ function ControlCombobox({
|
|||
}
|
||||
}, [isCollapsed]);
|
||||
|
||||
const rowRenderer = ({
|
||||
index,
|
||||
key,
|
||||
style,
|
||||
}: {
|
||||
index: number;
|
||||
key: string;
|
||||
style: React.CSSProperties;
|
||||
}) => {
|
||||
const item = matches[index];
|
||||
return (
|
||||
<Ariakit.SelectItem
|
||||
key={key}
|
||||
value={`${item.value ?? ''}`}
|
||||
aria-label={`${item.label ?? item.value ?? ''}`}
|
||||
className={cn(
|
||||
'flex cursor-pointer items-center px-3 text-sm',
|
||||
'text-text-primary hover:bg-surface-tertiary',
|
||||
'data-[active-item]:bg-surface-tertiary',
|
||||
)}
|
||||
render={<Ariakit.ComboboxItem />}
|
||||
style={style}
|
||||
>
|
||||
{item.icon != null && (
|
||||
<div className="assistant-item mr-2 flex h-5 w-5 items-center justify-center overflow-hidden rounded-full">
|
||||
{item.icon}
|
||||
</div>
|
||||
)}
|
||||
<span className="flex-grow truncate text-left">{item.label}</span>
|
||||
</Ariakit.SelectItem>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center justify-center px-1">
|
||||
<Ariakit.ComboboxProvider
|
||||
resetValueOnHide
|
||||
setValue={(value) => {
|
||||
startTransition(() => {
|
||||
setSearchValue(value);
|
||||
});
|
||||
}}
|
||||
<Ariakit.SelectLabel store={select} className="sr-only">
|
||||
{ariaLabel}
|
||||
</Ariakit.SelectLabel>
|
||||
<Ariakit.Select
|
||||
ref={buttonRef}
|
||||
store={select}
|
||||
className={cn(
|
||||
'flex items-center justify-center gap-2 rounded-full bg-surface-secondary',
|
||||
'text-text-primary hover:bg-surface-tertiary',
|
||||
'border border-border-light',
|
||||
isCollapsed ? 'h-10 w-10' : 'h-10 w-full rounded-md px-3 py-2 text-sm',
|
||||
)}
|
||||
>
|
||||
<Ariakit.SelectProvider value={selectedValue} setValue={setValue}>
|
||||
<Ariakit.SelectLabel className="sr-only">{ariaLabel}</Ariakit.SelectLabel>
|
||||
<Ariakit.Select
|
||||
ref={buttonRef}
|
||||
className={cn(
|
||||
'flex items-center justify-center gap-2 rounded-full bg-surface-secondary',
|
||||
'text-text-primary hover:bg-surface-tertiary',
|
||||
'border border-border-light',
|
||||
isCollapsed ? 'h-10 w-10' : 'h-10 w-full rounded-md px-3 py-2 text-sm',
|
||||
)}
|
||||
>
|
||||
{SelectIcon != null && (
|
||||
<div className="assistant-item flex h-5 w-5 items-center justify-center overflow-hidden rounded-full">
|
||||
{SelectIcon}
|
||||
</div>
|
||||
)}
|
||||
{!isCollapsed && (
|
||||
<span className="flex-grow truncate text-left">
|
||||
{displayValue ?? selectPlaceholder}
|
||||
</span>
|
||||
)}
|
||||
</Ariakit.Select>
|
||||
<Ariakit.SelectPopover
|
||||
gutter={4}
|
||||
portal
|
||||
className="z-50 overflow-hidden rounded-md border border-border-light bg-surface-secondary shadow-lg"
|
||||
style={{ width: isCollapsed ? '300px' : buttonWidth ?? '300px' }}
|
||||
>
|
||||
<div className="p-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-text-primary" />
|
||||
<Ariakit.Combobox
|
||||
autoSelect
|
||||
placeholder={searchPlaceholder}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-[50vh]">
|
||||
<AutoSizer disableHeight>
|
||||
{({ width }) => (
|
||||
<List
|
||||
width={width}
|
||||
height={Math.min(matches.length * ROW_HEIGHT, 300)}
|
||||
rowCount={matches.length}
|
||||
rowHeight={ROW_HEIGHT}
|
||||
rowRenderer={rowRenderer}
|
||||
overscanRowCount={5}
|
||||
/>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</div>
|
||||
</Ariakit.SelectPopover>
|
||||
</Ariakit.SelectProvider>
|
||||
</Ariakit.ComboboxProvider>
|
||||
{SelectIcon != null && (
|
||||
<div className="assistant-item flex h-5 w-5 items-center justify-center overflow-hidden rounded-full">
|
||||
{SelectIcon}
|
||||
</div>
|
||||
)}
|
||||
{!isCollapsed && (
|
||||
<span className="flex-grow truncate text-left">{displayValue ?? selectPlaceholder}</span>
|
||||
)}
|
||||
</Ariakit.Select>
|
||||
<Ariakit.SelectPopover
|
||||
store={select}
|
||||
gutter={4}
|
||||
portal
|
||||
className="z-50 overflow-hidden rounded-md border border-border-light bg-surface-secondary shadow-lg"
|
||||
style={{ width: isCollapsed ? '300px' : (buttonWidth ?? '300px') }}
|
||||
>
|
||||
<div className="p-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-text-primary" />
|
||||
<Ariakit.Combobox
|
||||
store={combobox}
|
||||
autoSelect
|
||||
placeholder={searchPlaceholder}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-[300px] overflow-auto">
|
||||
<Ariakit.ComboboxList store={combobox}>
|
||||
<SelectRenderer store={select} items={matches} itemSize={ROW_HEIGHT} overscan={5}>
|
||||
{({ value, icon, label, ...item }) => (
|
||||
<Ariakit.ComboboxItem
|
||||
key={item.id}
|
||||
{...item}
|
||||
className={cn(
|
||||
'flex w-full cursor-pointer items-center px-3 text-sm',
|
||||
'text-text-primary hover:bg-surface-tertiary',
|
||||
'data-[active-item]:bg-surface-tertiary',
|
||||
)}
|
||||
render={<Ariakit.SelectItem value={value} />}
|
||||
>
|
||||
{icon != null && (
|
||||
<div className="assistant-item mr-2 flex h-5 w-5 items-center justify-center overflow-hidden rounded-full">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
<span className="flex-grow truncate text-left">{label}</span>
|
||||
</Ariakit.ComboboxItem>
|
||||
)}
|
||||
</SelectRenderer>
|
||||
</Ariakit.ComboboxList>
|
||||
</div>
|
||||
</Ariakit.SelectPopover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
52
package-lock.json
generated
52
package-lock.json
generated
|
@ -1598,7 +1598,8 @@
|
|||
"version": "v0.7.7-rc1",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@ariakit/react": "^0.4.11",
|
||||
"@ariakit/react": "^0.4.15",
|
||||
"@ariakit/react-core": "^0.4.15",
|
||||
"@codesandbox/sandpack-react": "^2.19.10",
|
||||
"@dicebear/collection": "^7.0.4",
|
||||
"@dicebear/core": "^7.0.4",
|
||||
|
@ -1711,6 +1712,22 @@
|
|||
"vite-plugin-pwa": "^0.21.1"
|
||||
}
|
||||
},
|
||||
"client/node_modules/@ariakit/react": {
|
||||
"version": "0.4.15",
|
||||
"resolved": "https://registry.npmjs.org/@ariakit/react/-/react-0.4.15.tgz",
|
||||
"integrity": "sha512-0V2LkNPFrGRT+SEIiObx/LQjR6v3rR+mKEDUu/3tq7jfCZ+7+6Q6EMR1rFaK+XMkaRY1RWUcj/rRDWAUWnsDww==",
|
||||
"dependencies": {
|
||||
"@ariakit/react-core": "0.4.15"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/ariakit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"client/node_modules/@babel/code-frame": {
|
||||
"version": "7.26.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz",
|
||||
|
@ -2818,35 +2835,16 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@ariakit/core": {
|
||||
"version": "0.4.10",
|
||||
"resolved": "https://registry.npmjs.org/@ariakit/core/-/core-0.4.10.tgz",
|
||||
"integrity": "sha512-mX3EabQbfVh5uTjsTJ3+gjj7KGdTNhIN0qZHJd5Z2iPUnKl9NBy23Lgu6PEskpVsKAZ3proirjguD7U9fKMs/A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@ariakit/react": {
|
||||
"version": "0.4.11",
|
||||
"resolved": "https://registry.npmjs.org/@ariakit/react/-/react-0.4.11.tgz",
|
||||
"integrity": "sha512-nLpPrmNcspqNhk4o+epsgeZfP1+Fkh4uIzNe5yrFkXolRkqHGKAxl4Hi82e0yxIBUbYbZIEwsZQQVceF1L6xrw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ariakit/react-core": "0.4.11"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/ariakit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
"version": "0.4.14",
|
||||
"resolved": "https://registry.npmjs.org/@ariakit/core/-/core-0.4.14.tgz",
|
||||
"integrity": "sha512-hpzZvyYzGhP09S9jW1XGsU/FD5K3BKsH1eG/QJ8rfgEeUdPS7BvHPt5lHbOeJ2cMrRzBEvsEzLi1ivfDifHsVA=="
|
||||
},
|
||||
"node_modules/@ariakit/react-core": {
|
||||
"version": "0.4.11",
|
||||
"resolved": "https://registry.npmjs.org/@ariakit/react-core/-/react-core-0.4.11.tgz",
|
||||
"integrity": "sha512-i6KedWhjZkNC7tMEKO0eNjjq2HRPiHyGaBS2x2VaWwzBepoYtjyvxRXyqLJ3gaiNdlwckN1TZsRDfD+viy13IQ==",
|
||||
"license": "MIT",
|
||||
"version": "0.4.15",
|
||||
"resolved": "https://registry.npmjs.org/@ariakit/react-core/-/react-core-0.4.15.tgz",
|
||||
"integrity": "sha512-Up8+U97nAPJdyUh9E8BCEhJYTA+eVztWpHoo1R9zZfHd4cnBWAg5RHxEmMH+MamlvuRxBQA71hFKY/735fDg+A==",
|
||||
"dependencies": {
|
||||
"@ariakit/core": "0.4.10",
|
||||
"@ariakit/core": "0.4.14",
|
||||
"@floating-ui/dom": "^1.0.0",
|
||||
"use-sync-external-store": "^1.2.0"
|
||||
},
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue