mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-17 17:00:15 +01:00
📱 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:
parent
b22f1c166e
commit
87d95a9d82
9 changed files with 185 additions and 37 deletions
|
|
@ -28,7 +28,7 @@
|
|||
},
|
||||
"homepage": "https://librechat.ai",
|
||||
"dependencies": {
|
||||
"@ariakit/react": "^0.4.5",
|
||||
"@ariakit/react": "^0.4.8",
|
||||
"@dicebear/collection": "^7.0.4",
|
||||
"@dicebear/core": "^7.0.4",
|
||||
"@headlessui/react": "^2.1.2",
|
||||
|
|
|
|||
|
|
@ -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 ?? ''}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
123
client/src/components/ui/ControlCombobox.tsx
Normal file
123
client/src/components/ui/ControlCombobox.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
import { v4 } from 'uuid';
|
||||
import { useCallback } from 'react';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useCallback } from 'react';
|
||||
import {
|
||||
QueryKeys,
|
||||
Constants,
|
||||
|
|
@ -29,6 +30,7 @@ import useContentHandler from '~/hooks/SSE/useContentHandler';
|
|||
import type { TGenTitleMutation } from '~/data-provider';
|
||||
import { useAuthContext } from '~/hooks/AuthContext';
|
||||
import { useLiveAnnouncer } from '~/Providers';
|
||||
import store from '~/store';
|
||||
|
||||
type TSyncData = {
|
||||
sync: boolean;
|
||||
|
|
@ -65,6 +67,7 @@ export default function useEventHandlers({
|
|||
resetLatestMessage,
|
||||
}: EventHandlerParams) {
|
||||
const queryClient = useQueryClient();
|
||||
const setAbortScroll = useSetRecoilState(store.abortScroll);
|
||||
const { announcePolite, announceAssertive } = useLiveAnnouncer();
|
||||
|
||||
const { conversationId: paramId } = useParams();
|
||||
|
|
@ -306,15 +309,16 @@ export default function useEventHandlers({
|
|||
resetLatestMessage();
|
||||
}
|
||||
|
||||
scrollToEnd();
|
||||
scrollToEnd(() => setAbortScroll(false));
|
||||
},
|
||||
[
|
||||
setMessages,
|
||||
setConversation,
|
||||
queryClient,
|
||||
setAbortScroll,
|
||||
isAddedRequest,
|
||||
resetLatestMessage,
|
||||
setConversation,
|
||||
announceAssertive,
|
||||
resetLatestMessage,
|
||||
],
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -44,9 +44,12 @@ export const getTextKey = (message?: TMessage | null, convoId?: string | null) =
|
|||
}${message.conversationId ?? convoId}`;
|
||||
};
|
||||
|
||||
export const scrollToEnd = () => {
|
||||
export const scrollToEnd = (callback?: () => void) => {
|
||||
const messagesEndElement = document.getElementById('messages-end');
|
||||
if (messagesEndElement) {
|
||||
messagesEndElement.scrollIntoView({ behavior: 'instant' });
|
||||
if (callback) {
|
||||
callback();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
32
package-lock.json
generated
32
package-lock.json
generated
|
|
@ -1117,7 +1117,7 @@
|
|||
"version": "0.7.4",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@ariakit/react": "^0.4.5",
|
||||
"@ariakit/react": "^0.4.8",
|
||||
"@dicebear/collection": "^7.0.4",
|
||||
"@dicebear/core": "^7.0.4",
|
||||
"@headlessui/react": "^2.1.2",
|
||||
|
|
@ -1289,38 +1289,38 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@ariakit/core": {
|
||||
"version": "0.4.5",
|
||||
"resolved": "https://registry.npmjs.org/@ariakit/core/-/core-0.4.5.tgz",
|
||||
"integrity": "sha512-e294+bEcyzt/H/kO4fS5/czLAlkF7PY+Kul3q2z54VY+GGay8NlVs9UezAB7L4jUBlYRAXwp7/1Sq3R7b+MZ7w=="
|
||||
"version": "0.4.8",
|
||||
"resolved": "https://registry.npmjs.org/@ariakit/core/-/core-0.4.8.tgz",
|
||||
"integrity": "sha512-HQS+9CI7pMqqVlAt5bPGenT0/e65UxXY+PKtgU7Y+0UToBDBRolO5S9+UUSDm8OmJHSnq24owEGm1Mv28l5XCQ=="
|
||||
},
|
||||
"node_modules/@ariakit/react": {
|
||||
"version": "0.4.5",
|
||||
"resolved": "https://registry.npmjs.org/@ariakit/react/-/react-0.4.5.tgz",
|
||||
"integrity": "sha512-GUHxaOY1JZrJUHkuV20IY4NWcgknhqTQM0qCQcVZDCi+pJiWchUjTG+UyIr/Of02hU569qnQ7yovskCf+V3tNg==",
|
||||
"version": "0.4.8",
|
||||
"resolved": "https://registry.npmjs.org/@ariakit/react/-/react-0.4.8.tgz",
|
||||
"integrity": "sha512-Bb1vOrp0X52hxi1wE9TEHjjZ/Y08tVq2ZH+RFDwRQB3g04uVwrrhnTccHepC6rsObrDpAOV3/YlJCi4k/lSUaQ==",
|
||||
"dependencies": {
|
||||
"@ariakit/react-core": "0.4.5"
|
||||
"@ariakit/react-core": "0.4.8"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/ariakit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^17.0.0 || ^18.0.0",
|
||||
"react-dom": "^17.0.0 || ^18.0.0"
|
||||
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@ariakit/react-core": {
|
||||
"version": "0.4.5",
|
||||
"resolved": "https://registry.npmjs.org/@ariakit/react-core/-/react-core-0.4.5.tgz",
|
||||
"integrity": "sha512-ciTYPwpj/+mdA+EstveEnoygbx5e4PXQJxfkLKy4lkTkDJJUS9GcbYhdnIFJVUta6P1YFvzkIKo+/y9mcbMKJg==",
|
||||
"version": "0.4.8",
|
||||
"resolved": "https://registry.npmjs.org/@ariakit/react-core/-/react-core-0.4.8.tgz",
|
||||
"integrity": "sha512-TzsddUWQwWYhrEVWHA/Gf7KCGx8rwFohAHfuljjqidKeZi2kUmuRAImCTG9oga34FWHFf4AdXQbBKclMNt0nrQ==",
|
||||
"dependencies": {
|
||||
"@ariakit/core": "0.4.5",
|
||||
"@ariakit/core": "0.4.8",
|
||||
"@floating-ui/dom": "^1.0.0",
|
||||
"use-sync-external-store": "^1.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^17.0.0 || ^18.0.0",
|
||||
"react-dom": "^17.0.0 || ^18.0.0"
|
||||
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-crypto/crc32": {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue