Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

🔧 fix: Ariakit Combobox Virtualization #5851

Merged
merged 1 commit into from
Feb 13, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
184 changes: 88 additions & 96 deletions client/src/components/ui/ControlCombobox.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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(() => {
Expand All @@ -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 ?? ''}`}
return (
<div className="flex w-full items-center justify-center px-1">
<Ariakit.SelectLabel store={select} className="sr-only">
{ariaLabel}
</Ariakit.SelectLabel>
<Ariakit.Select
ref={buttonRef}
store={select}
className={cn(
'flex cursor-pointer items-center px-3 text-sm',
'flex items-center justify-center gap-2 rounded-full bg-surface-secondary',
'text-text-primary hover:bg-surface-tertiary',
'data-[active-item]:bg-surface-tertiary',
'border border-border-light',
isCollapsed ? 'h-10 w-10' : 'h-10 w-full rounded-md px-3 py-2 text-sm',
)}
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}
{SelectIcon != null && (
<div className="assistant-item flex h-5 w-5 items-center justify-center overflow-hidden rounded-full">
{SelectIcon}
</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);
});
}}
{!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') }}
>
<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>
<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>
);
}
Expand Down
52 changes: 25 additions & 27 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading