📱 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

@ -28,7 +28,7 @@
}, },
"homepage": "https://librechat.ai", "homepage": "https://librechat.ai",
"dependencies": { "dependencies": {
"@ariakit/react": "^0.4.5", "@ariakit/react": "^0.4.8",
"@dicebear/collection": "^7.0.4", "@dicebear/collection": "^7.0.4",
"@dicebear/core": "^7.0.4", "@dicebear/core": "^7.0.4",
"@headlessui/react": "^2.1.2", "@headlessui/react": "^2.1.2",

View file

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

View file

@ -9,7 +9,7 @@ export default function Switcher(props: SwitcherProps) {
return ( return (
<> <>
<AssistantSwitcher {...props} /> <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)) { } else if (isAssistantsEndpoint(props.endpoint)) {
@ -19,7 +19,7 @@ export default function Switcher(props: SwitcherProps) {
return ( return (
<> <>
<ModelSwitcher {...props} /> <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 { Search as SearchIcon } from 'lucide-react';
import * as RadixSelect from '@radix-ui/react-select'; import * as RadixSelect from '@radix-ui/react-select';
import { CheckIcon, ChevronDownIcon } from '@radix-ui/react-icons'; import { CheckIcon, ChevronDownIcon } from '@radix-ui/react-icons';
@ -52,7 +52,16 @@ export default function ComboboxComponent({
value={selectedValue} value={selectedValue}
onValueChange={setValue} onValueChange={setValue}
open={open} 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 <ComboboxProvider
open={open} 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', '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', '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"> <span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<RadixSelect.ItemIndicator> <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>
);
}

View file

@ -1,7 +1,8 @@
import { v4 } from 'uuid'; import { v4 } from 'uuid';
import { useCallback } from 'react';
import { useSetRecoilState } from 'recoil';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { useCallback } from 'react';
import { import {
QueryKeys, QueryKeys,
Constants, Constants,
@ -29,6 +30,7 @@ import useContentHandler from '~/hooks/SSE/useContentHandler';
import type { TGenTitleMutation } from '~/data-provider'; import type { TGenTitleMutation } from '~/data-provider';
import { useAuthContext } from '~/hooks/AuthContext'; import { useAuthContext } from '~/hooks/AuthContext';
import { useLiveAnnouncer } from '~/Providers'; import { useLiveAnnouncer } from '~/Providers';
import store from '~/store';
type TSyncData = { type TSyncData = {
sync: boolean; sync: boolean;
@ -65,6 +67,7 @@ export default function useEventHandlers({
resetLatestMessage, resetLatestMessage,
}: EventHandlerParams) { }: EventHandlerParams) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const setAbortScroll = useSetRecoilState(store.abortScroll);
const { announcePolite, announceAssertive } = useLiveAnnouncer(); const { announcePolite, announceAssertive } = useLiveAnnouncer();
const { conversationId: paramId } = useParams(); const { conversationId: paramId } = useParams();
@ -306,15 +309,16 @@ export default function useEventHandlers({
resetLatestMessage(); resetLatestMessage();
} }
scrollToEnd(); scrollToEnd(() => setAbortScroll(false));
}, },
[ [
setMessages, setMessages,
setConversation,
queryClient, queryClient,
setAbortScroll,
isAddedRequest, isAddedRequest,
resetLatestMessage, setConversation,
announceAssertive, announceAssertive,
resetLatestMessage,
], ],
); );

View file

@ -44,9 +44,12 @@ export const getTextKey = (message?: TMessage | null, convoId?: string | null) =
}${message.conversationId ?? convoId}`; }${message.conversationId ?? convoId}`;
}; };
export const scrollToEnd = () => { export const scrollToEnd = (callback?: () => void) => {
const messagesEndElement = document.getElementById('messages-end'); const messagesEndElement = document.getElementById('messages-end');
if (messagesEndElement) { if (messagesEndElement) {
messagesEndElement.scrollIntoView({ behavior: 'instant' }); messagesEndElement.scrollIntoView({ behavior: 'instant' });
if (callback) {
callback();
}
} }
}; };

32
package-lock.json generated
View file

@ -1117,7 +1117,7 @@
"version": "0.7.4", "version": "0.7.4",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@ariakit/react": "^0.4.5", "@ariakit/react": "^0.4.8",
"@dicebear/collection": "^7.0.4", "@dicebear/collection": "^7.0.4",
"@dicebear/core": "^7.0.4", "@dicebear/core": "^7.0.4",
"@headlessui/react": "^2.1.2", "@headlessui/react": "^2.1.2",
@ -1289,38 +1289,38 @@
} }
}, },
"node_modules/@ariakit/core": { "node_modules/@ariakit/core": {
"version": "0.4.5", "version": "0.4.8",
"resolved": "https://registry.npmjs.org/@ariakit/core/-/core-0.4.5.tgz", "resolved": "https://registry.npmjs.org/@ariakit/core/-/core-0.4.8.tgz",
"integrity": "sha512-e294+bEcyzt/H/kO4fS5/czLAlkF7PY+Kul3q2z54VY+GGay8NlVs9UezAB7L4jUBlYRAXwp7/1Sq3R7b+MZ7w==" "integrity": "sha512-HQS+9CI7pMqqVlAt5bPGenT0/e65UxXY+PKtgU7Y+0UToBDBRolO5S9+UUSDm8OmJHSnq24owEGm1Mv28l5XCQ=="
}, },
"node_modules/@ariakit/react": { "node_modules/@ariakit/react": {
"version": "0.4.5", "version": "0.4.8",
"resolved": "https://registry.npmjs.org/@ariakit/react/-/react-0.4.5.tgz", "resolved": "https://registry.npmjs.org/@ariakit/react/-/react-0.4.8.tgz",
"integrity": "sha512-GUHxaOY1JZrJUHkuV20IY4NWcgknhqTQM0qCQcVZDCi+pJiWchUjTG+UyIr/Of02hU569qnQ7yovskCf+V3tNg==", "integrity": "sha512-Bb1vOrp0X52hxi1wE9TEHjjZ/Y08tVq2ZH+RFDwRQB3g04uVwrrhnTccHepC6rsObrDpAOV3/YlJCi4k/lSUaQ==",
"dependencies": { "dependencies": {
"@ariakit/react-core": "0.4.5" "@ariakit/react-core": "0.4.8"
}, },
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
"url": "https://opencollective.com/ariakit" "url": "https://opencollective.com/ariakit"
}, },
"peerDependencies": { "peerDependencies": {
"react": "^17.0.0 || ^18.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0" "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
} }
}, },
"node_modules/@ariakit/react-core": { "node_modules/@ariakit/react-core": {
"version": "0.4.5", "version": "0.4.8",
"resolved": "https://registry.npmjs.org/@ariakit/react-core/-/react-core-0.4.5.tgz", "resolved": "https://registry.npmjs.org/@ariakit/react-core/-/react-core-0.4.8.tgz",
"integrity": "sha512-ciTYPwpj/+mdA+EstveEnoygbx5e4PXQJxfkLKy4lkTkDJJUS9GcbYhdnIFJVUta6P1YFvzkIKo+/y9mcbMKJg==", "integrity": "sha512-TzsddUWQwWYhrEVWHA/Gf7KCGx8rwFohAHfuljjqidKeZi2kUmuRAImCTG9oga34FWHFf4AdXQbBKclMNt0nrQ==",
"dependencies": { "dependencies": {
"@ariakit/core": "0.4.5", "@ariakit/core": "0.4.8",
"@floating-ui/dom": "^1.0.0", "@floating-ui/dom": "^1.0.0",
"use-sync-external-store": "^1.2.0" "use-sync-external-store": "^1.2.0"
}, },
"peerDependencies": { "peerDependencies": {
"react": "^17.0.0 || ^18.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0" "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
} }
}, },
"node_modules/@aws-crypto/crc32": { "node_modules/@aws-crypto/crc32": {