🙌 a11y: Searchbar/Conversations List Focus (#7096)

* chore: remove redundancy of useSetRecoilState and useRecoilValue with useRecoilState in SearchBar

* refactor: remove unnecessary focus effect on text area in ChatForm

* refactor: improve searchbar and clear search button accessibility

* fix: add tabIndex to Conversations component for improved accessibility, moves focus directly conversation items

* style: adjust margin in Header component for improved layout symmetry with Nav

* chore: imports order
This commit is contained in:
Danny Avila 2025-04-27 15:13:19 -04:00 committed by GitHub
parent 550c7cc68a
commit 6826c0ed43
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 18 additions and 15 deletions

View file

@ -36,7 +36,7 @@ export default function Header() {
return ( return (
<div className="sticky top-0 z-10 flex h-14 w-full items-center justify-between bg-white p-2 font-semibold text-text-primary dark:bg-gray-800"> <div className="sticky top-0 z-10 flex h-14 w-full items-center justify-between bg-white p-2 font-semibold text-text-primary dark:bg-gray-800">
<div className="hide-scrollbar flex w-full items-center justify-between gap-2 overflow-x-auto"> <div className="hide-scrollbar flex w-full items-center justify-between gap-2 overflow-x-auto">
<div className="mx-2 flex items-center gap-2"> <div className="mx-1 flex items-center gap-2">
{!navVisible && <OpenSidebar setNavVisible={setNavVisible} />} {!navVisible && <OpenSidebar setNavVisible={setNavVisible} />}
{!navVisible && <HeaderNewChat />} {!navVisible && <HeaderNewChat />}
{<ModelSelector startupConfig={startupConfig} />} {<ModelSelector startupConfig={startupConfig} />}

View file

@ -151,12 +151,6 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
const textValue = useWatch({ control: methods.control, name: 'text' }); const textValue = useWatch({ control: methods.control, name: 'text' });
useEffect(() => {
if (!search.isSearching && textAreaRef.current && !disableInputs) {
textAreaRef.current.focus();
}
}, [search.isSearching, disableInputs]);
useEffect(() => { useEffect(() => {
if (textAreaRef.current) { if (textAreaRef.current) {
const style = window.getComputedStyle(textAreaRef.current); const style = window.getComputedStyle(textAreaRef.current);

View file

@ -220,6 +220,7 @@ const Conversations: FC<ConversationsProps> = ({
role="list" role="list"
aria-label="Conversations" aria-label="Conversations"
onRowsRendered={handleRowsRendered} onRowsRendered={handleRowsRendered}
tabIndex={-1}
/> />
)} )}
</AutoSizer> </AutoSizer>

View file

@ -1,7 +1,7 @@
import { forwardRef, useState, useCallback, useMemo, useEffect, Ref } from 'react'; import React, { forwardRef, useState, useCallback, useMemo, useEffect, useRef } from 'react';
import debounce from 'lodash/debounce'; import debounce from 'lodash/debounce';
import { useRecoilState } from 'recoil';
import { Search, X } from 'lucide-react'; import { Search, X } from 'lucide-react';
import { useSetRecoilState, useRecoilValue } from 'recoil';
import { QueryKeys } from 'librechat-data-provider'; import { QueryKeys } from 'librechat-data-provider';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
@ -13,7 +13,7 @@ type SearchBarProps = {
isSmallScreen?: boolean; isSmallScreen?: boolean;
}; };
const SearchBar = forwardRef((props: SearchBarProps, ref: Ref<HTMLDivElement>) => { const SearchBar = forwardRef((props: SearchBarProps, ref: React.Ref<HTMLDivElement>) => {
const localize = useLocalize(); const localize = useLocalize();
const location = useLocation(); const location = useLocation();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@ -21,11 +21,11 @@ const SearchBar = forwardRef((props: SearchBarProps, ref: Ref<HTMLDivElement>) =
const { isSmallScreen } = props; const { isSmallScreen } = props;
const [text, setText] = useState(''); const [text, setText] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
const [showClearIcon, setShowClearIcon] = useState(false); const [showClearIcon, setShowClearIcon] = useState(false);
const { newConversation } = useNewConvo(); const { newConversation } = useNewConvo();
const setSearchState = useSetRecoilState(store.search); const [search, setSearchState] = useRecoilState(store.search);
const search = useRecoilValue(store.search);
const clearSearch = useCallback(() => { const clearSearch = useCallback(() => {
if (location.pathname.includes('/search')) { if (location.pathname.includes('/search')) {
@ -44,6 +44,7 @@ const SearchBar = forwardRef((props: SearchBarProps, ref: Ref<HTMLDivElement>) =
isTyping: false, isTyping: false,
})); }));
clearSearch(); clearSearch();
inputRef.current?.focus();
}, [setSearchState, clearSearch]); }, [setSearchState, clearSearch]);
const handleKeyUp = (e: React.KeyboardEvent<HTMLInputElement>) => { const handleKeyUp = (e: React.KeyboardEvent<HTMLInputElement>) => {
@ -108,6 +109,7 @@ const SearchBar = forwardRef((props: SearchBarProps, ref: Ref<HTMLDivElement>) =
<Search className="absolute left-3 h-4 w-4 text-text-secondary group-focus-within:text-text-primary group-hover:text-text-primary" /> <Search className="absolute left-3 h-4 w-4 text-text-secondary group-focus-within:text-text-primary group-hover:text-text-primary" />
<input <input
type="text" type="text"
ref={inputRef}
className="m-0 mr-0 w-full border-none bg-transparent p-0 pl-7 text-sm leading-tight placeholder-text-secondary placeholder-opacity-100 focus-visible:outline-none group-focus-within:placeholder-text-primary group-hover:placeholder-text-primary" className="m-0 mr-0 w-full border-none bg-transparent p-0 pl-7 text-sm leading-tight placeholder-text-secondary placeholder-opacity-100 focus-visible:outline-none group-focus-within:placeholder-text-primary group-hover:placeholder-text-primary"
value={text} value={text}
onChange={onChange} onChange={onChange}
@ -122,14 +124,20 @@ const SearchBar = forwardRef((props: SearchBarProps, ref: Ref<HTMLDivElement>) =
autoComplete="off" autoComplete="off"
dir="auto" dir="auto"
/> />
<X <button
type="button"
aria-label={`${localize('com_ui_clear')} ${localize('com_ui_search')}`}
className={cn( className={cn(
'absolute right-[7px] h-5 w-5 cursor-pointer transition-opacity duration-200', 'absolute right-[7px] flex h-5 w-5 items-center justify-center rounded-full border-none bg-transparent p-0 transition-opacity duration-200',
showClearIcon ? 'opacity-100' : 'opacity-0', showClearIcon ? 'opacity-100' : 'opacity-0',
isSmallScreen === true ? 'right-[16px]' : '', isSmallScreen === true ? 'right-[16px]' : '',
)} )}
onClick={clearText} onClick={clearText}
/> tabIndex={showClearIcon ? 0 : -1}
disabled={!showClearIcon}
>
<X className="h-5 w-5 cursor-pointer" />
</button>
</div> </div>
); );
}); });