📱 fix: Resolve Android Device and Accessibility Issues of Sidebar Combobox (#3689)

* chore: Update @ariakit/react dependency to version 0.4.8

* refactor: Fix Combobox Android issue with radix-ui

* fix: Improve scrolling behavior by setting abort scroll state to false after scrolling to end

* wip: first pass switcher rewrite

* feat: Add button width calculation for ComboboxComponent

* refactor: Update ComboboxComponent styling for improved layout and appearance

* refactor: Update AssistantSwitcher component to handle null values for assistant names and avatar URLs

* refactor: Update ModelSwitcher component to use SimpleCombobox for improved functionality and styling

* refactor: Update Switcher Separator styling for improved layout and appearance

* refactor: Improve accessibility by adding aria-label to ComboboxComponent select items

* refactor: rename SimpleCombobox -> ControlCombobox
This commit is contained in:
Danny Avila 2024-08-18 19:02:46 -04:00 committed by GitHub
parent b22f1c166e
commit 87d95a9d82
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 185 additions and 37 deletions

View file

@ -1,10 +1,10 @@
import { useEffect, useMemo } from 'react';
import { Combobox } from '~/components/ui';
import { isAssistantsEndpoint, LocalStorageKeys } from 'librechat-data-provider';
import type { AssistantsEndpoint } from 'librechat-data-provider';
import type { SwitcherProps, AssistantListItem } from '~/common';
import { useSetIndexOptions, useSelectAssistant, useLocalize, useAssistantListMap } from '~/hooks';
import { useChatContext, useAssistantsMapContext } from '~/Providers';
import ControlCombobox from '~/components/ui/ControlCombobox';
import Icon from '~/components/Endpoints/Icon';
export default function AssistantSwitcher({ isCollapsed }: SwitcherProps) {
@ -31,7 +31,7 @@ export default function AssistantSwitcher({ isCollapsed }: SwitcherProps) {
localStorage.getItem(`${LocalStorageKeys.ASST_ID_PREFIX}${index}${endpoint}`) ??
assistants[0]?.id ??
'';
const assistant = assistantMap?.[endpoint ?? '']?.[assistant_id];
const assistant = assistantMap[endpoint ?? ''][assistant_id];
if (!assistant) {
return;
@ -51,14 +51,14 @@ export default function AssistantSwitcher({ isCollapsed }: SwitcherProps) {
const assistantOptions = useMemo(() => {
return assistants.map((assistant) => {
return {
label: assistant.name ?? '',
label: (assistant.name as string | null) ?? '',
value: assistant.id,
icon: (
<Icon
isCreatedByUser={false}
endpoint={endpoint}
assistantName={assistant.name ?? ''}
iconURL={(assistant.metadata?.avatar as string) ?? ''}
assistantName={(assistant.name as string | null) ?? ''}
iconURL={assistant.metadata?.avatar ?? ''}
/>
),
};
@ -66,7 +66,7 @@ export default function AssistantSwitcher({ isCollapsed }: SwitcherProps) {
}, [assistants, endpoint]);
return (
<Combobox
<ControlCombobox
selectedValue={currentAssistant?.id ?? ''}
displayValue={
assistants.find((assistant) => assistant.id === selectedAssistant)?.name ??
@ -83,7 +83,7 @@ export default function AssistantSwitcher({ isCollapsed }: SwitcherProps) {
isCreatedByUser={false}
endpoint={endpoint}
assistantName={currentAssistant?.name ?? ''}
iconURL={(currentAssistant?.metadata?.avatar as string) ?? ''}
iconURL={currentAssistant?.metadata?.avatar ?? ''}
/>
}
/>

View file

@ -1,10 +1,10 @@
import { useMemo, useRef, useCallback } from 'react';
import { useGetModelsQuery } from 'librechat-data-provider/react-query';
import type { SwitcherProps } from '~/common';
import ControlCombobox from '~/components/ui/ControlCombobox';
import MinimalIcon from '~/components/Endpoints/MinimalIcon';
import { useSetIndexOptions, useLocalize } from '~/hooks';
import type { SwitcherProps } from '~/common';
import { useChatContext } from '~/Providers';
import { Combobox } from '~/components/ui';
import { mainTextareaId } from '~/common';
export default function ModelSwitcher({ isCollapsed }: SwitcherProps) {
@ -16,7 +16,10 @@ export default function ModelSwitcher({ isCollapsed }: SwitcherProps) {
const { endpoint, model = null } = conversation ?? {};
const models = useMemo(() => {
return modelsQuery?.data?.[endpoint ?? ''] ?? [];
return (modelsQuery.data?.[endpoint ?? ''] ?? []).map((model) => ({
label: model,
value: model,
}));
}, [modelsQuery, endpoint]);
const setModel = useCallback(
@ -34,7 +37,8 @@ export default function ModelSwitcher({ isCollapsed }: SwitcherProps) {
);
return (
<Combobox
<ControlCombobox
displayValue={model ?? ''}
selectPlaceholder={localize('com_ui_select_model')}
searchPlaceholder={localize('com_ui_select_search_model')}
isCollapsed={isCollapsed}

View file

@ -9,7 +9,7 @@ export default function Switcher(props: SwitcherProps) {
return (
<>
<AssistantSwitcher {...props} />
<Separator className="bg-gray-100/50 dark:bg-gray-600" />
<Separator className="max-w-[98%] bg-surface-tertiary" />
</>
);
} else if (isAssistantsEndpoint(props.endpoint)) {
@ -19,7 +19,7 @@ export default function Switcher(props: SwitcherProps) {
return (
<>
<ModelSwitcher {...props} />
<Separator className="bg-gray-100/50 dark:bg-gray-600" />
<Separator className="max-w-[98%] bg-surface-tertiary" />
</>
);
}

View file

@ -1,4 +1,4 @@
import { startTransition, useMemo } from 'react';
import { startTransition } from 'react';
import { Search as SearchIcon } from 'lucide-react';
import * as RadixSelect from '@radix-ui/react-select';
import { CheckIcon, ChevronDownIcon } from '@radix-ui/react-icons';
@ -52,7 +52,16 @@ export default function ComboboxComponent({
value={selectedValue}
onValueChange={setValue}
open={open}
onOpenChange={setOpen}
/** Hacky fix for radix-ui Android issue: https://github.com/radix-ui/primitives/issues/1658 */
onOpenChange={() => {
if (open === true) {
setOpen(false);
return;
}
setTimeout(() => {
setOpen(!open);
}, 75);
}}
>
<ComboboxProvider
open={open}
@ -134,6 +143,11 @@ export default function ComboboxComponent({
'focus:bg-accent focus:text-accent-foreground relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
'rounded-lg hover:bg-gray-100/50 hover:bg-gray-50 dark:text-white dark:hover:bg-gray-600',
)}
/** Hacky fix for radix-ui Android issue: https://github.com/radix-ui/primitives/issues/1658 */
onTouchEnd={() => {
setValue(`${value ?? ''}`);
setOpen(false);
}}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<RadixSelect.ItemIndicator>

View file

@ -0,0 +1,123 @@
import * as Ariakit from '@ariakit/react';
import { matchSorter } from 'match-sorter';
import { startTransition, useMemo, useState, useEffect, useRef } from 'react';
import { cn } from '~/utils';
import type { OptionWithIcon } from '~/common';
import { Search } from 'lucide-react';
interface ControlComboboxProps {
selectedValue: string;
displayValue?: string;
items: OptionWithIcon[];
setValue: (value: string) => void;
ariaLabel: string;
searchPlaceholder?: string;
selectPlaceholder?: string;
isCollapsed: boolean;
SelectIcon?: React.ReactNode;
}
export default function ControlCombobox({
selectedValue,
displayValue,
items,
setValue,
ariaLabel,
searchPlaceholder,
selectPlaceholder,
isCollapsed,
SelectIcon,
}: ControlComboboxProps) {
const [searchValue, setSearchValue] = useState('');
const buttonRef = useRef<HTMLButtonElement>(null);
const [buttonWidth, setButtonWidth] = useState<number | null>(null);
const matches = useMemo(() => {
return matchSorter(items, searchValue, {
keys: ['value', 'label'],
baseSort: (a, b) => (a.index < b.index ? -1 : 1),
});
}, [searchValue, items]);
useEffect(() => {
if (buttonRef.current && !isCollapsed) {
setButtonWidth(buttonRef.current.offsetWidth);
}
}, [isCollapsed]);
return (
<div className="flex w-full items-center justify-center px-1">
<Ariakit.ComboboxProvider
resetValueOnHide
setValue={(value) => {
startTransition(() => {
setSearchValue(value);
});
}}
>
<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>
<Ariakit.ComboboxList className="max-h-[50vh] overflow-auto">
{matches.map((item) => (
<Ariakit.SelectItem
key={item.value}
value={`${item.value ?? ''}`}
aria-label={`${item.label ?? item.value ?? ''}`}
className={cn(
'flex cursor-pointer items-center px-3 py-2 text-sm',
'text-text-primary hover:bg-surface-tertiary',
'data-[active-item]:bg-surface-tertiary',
)}
render={<Ariakit.ComboboxItem />}
>
{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>
))}
</Ariakit.ComboboxList>
</Ariakit.SelectPopover>
</Ariakit.SelectProvider>
</Ariakit.ComboboxProvider>
</div>
);
}