mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-31 23:58:50 +01:00
⌨️ refactor: Favorite Item Selection & Keyboard Navigation/Focus Improvements (#10952)
* refactor: Reuse conversation switching logic from useSelectMention hook for Favorite Items - Added onSelectEndpoint prop to FavoriteItem for improved endpoint selection handling. - Refactored conversation initiation logic to utilize the new prop instead of direct navigation. - Updated FavoritesList to pass onSelectEndpoint to FavoriteItem, streamlining the interaction flow. - Replaced EndpointIcon with MinimalIcon for a cleaner UI representation of favorite models. * refactor: Enhance FavoriteItem and FavoritesList for improved accessibility and interaction - Added onRemoveFocus prop to FavoriteItem for better focus management after item removal. - Refactored event handling in FavoriteItem to support keyboard interactions for accessibility. - Updated FavoritesList to utilize the new onRemoveFocus prop, ensuring focus shifts appropriately after removing favorites. - Enhanced aria-labels and roles for better screen reader support and user experience. * refactor: Enhance EndpointModelItem for improved accessibility and interaction - Added useRef and useState hooks to manage active state and focus behavior. - Implemented MutationObserver to track changes in the data-active-item attribute for better accessibility. - Refactored favorite button handling to improve interaction and accessibility. - Updated button tabIndex based on active state to enhance keyboard navigation. * chore: Update Radix UI dependencies in package-lock and package.json files - Upgraded @radix-ui/react-alert-dialog and @radix-ui/react-dialog to version 1.1.15 across client and packages/client. - Updated related dependencies for improved compatibility and performance. - Removed outdated debug module references from package-lock.json. * refactor: Improve accessibility and interaction in conversation options - Added event handling to prevent unintended actions when renaming conversations. - Updated ConvoOptions to use Ariakit components for better accessibility and interaction. - Refactored button handlers for sharing and deleting conversations for clarity and consistency. - Enhanced dialog components with proper aria attributes and improved structure for better screen reader support. * refactor: Improve nested dialog accessibility for deleting shared link - Eliminated the setShareDialogOpen prop from both ShareButton and SharedLinkButton components to streamline the code. - Updated the delete mutation success handler in SharedLinkButton to improve focus management for accessibility. - Enhanced the OGDialog component in SharedLinkButton with a triggerRef for better interaction.
This commit is contained in:
parent
5b0cce2e2a
commit
4d7e6b4a58
12 changed files with 670 additions and 219 deletions
|
|
@ -1,60 +1,59 @@
|
|||
import React, { useState } from 'react';
|
||||
import * as Menu from '@ariakit/react/menu';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Ellipsis, PinOff } from 'lucide-react';
|
||||
import { DropdownPopup } from '@librechat/client';
|
||||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import type { FavoriteModel } from '~/store/favorites';
|
||||
import type t from 'librechat-data-provider';
|
||||
import EndpointIcon from '~/components/Endpoints/EndpointIcon';
|
||||
import { useNewConvo, useFavorites, useLocalize } from '~/hooks';
|
||||
import MinimalIcon from '~/components/Endpoints/MinimalIcon';
|
||||
import { useFavorites, useLocalize } from '~/hooks';
|
||||
import { renderAgentAvatar, cn } from '~/utils';
|
||||
|
||||
type Kwargs = {
|
||||
model?: string;
|
||||
agent_id?: string;
|
||||
assistant_id?: string;
|
||||
spec?: string | null;
|
||||
};
|
||||
|
||||
type FavoriteItemProps = {
|
||||
item: t.Agent | FavoriteModel;
|
||||
type: 'agent' | 'model';
|
||||
onSelectEndpoint?: (endpoint?: EModelEndpoint | string | null, kwargs?: Kwargs) => void;
|
||||
onRemoveFocus?: () => void;
|
||||
};
|
||||
|
||||
export default function FavoriteItem({ item, type }: FavoriteItemProps) {
|
||||
const navigate = useNavigate();
|
||||
export default function FavoriteItem({
|
||||
item,
|
||||
type,
|
||||
onSelectEndpoint,
|
||||
onRemoveFocus,
|
||||
}: FavoriteItemProps) {
|
||||
const localize = useLocalize();
|
||||
const { newConversation } = useNewConvo();
|
||||
const { removeFavoriteAgent, removeFavoriteModel } = useFavorites();
|
||||
const [isPopoverActive, setIsPopoverActive] = useState(false);
|
||||
|
||||
const handleSelect = () => {
|
||||
if (type === 'agent') {
|
||||
const agent = item as t.Agent;
|
||||
onSelectEndpoint?.(EModelEndpoint.agents, { agent_id: agent.id });
|
||||
} else {
|
||||
const model = item as FavoriteModel;
|
||||
onSelectEndpoint?.(model.endpoint, { model: model.model });
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
if ((e.target as HTMLElement).closest('[data-testid="favorite-options-button"]')) {
|
||||
return;
|
||||
}
|
||||
handleSelect();
|
||||
};
|
||||
|
||||
if (type === 'agent') {
|
||||
const agent = item as t.Agent;
|
||||
newConversation({
|
||||
template: {
|
||||
...agent,
|
||||
endpoint: EModelEndpoint.agents,
|
||||
agent_id: agent.id,
|
||||
},
|
||||
preset: {
|
||||
...agent,
|
||||
endpoint: EModelEndpoint.agents,
|
||||
agent_id: agent.id,
|
||||
},
|
||||
});
|
||||
navigate(`/c/new`);
|
||||
} else {
|
||||
const model = item as FavoriteModel;
|
||||
newConversation({
|
||||
template: {
|
||||
endpoint: model.endpoint,
|
||||
model: model.model,
|
||||
},
|
||||
preset: {
|
||||
endpoint: model.endpoint,
|
||||
model: model.model,
|
||||
},
|
||||
});
|
||||
navigate(`/c/new`);
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleSelect();
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -67,6 +66,9 @@ export default function FavoriteItem({ item, type }: FavoriteItemProps) {
|
|||
removeFavoriteModel(model.model, model.endpoint);
|
||||
}
|
||||
setIsPopoverActive(false);
|
||||
requestAnimationFrame(() => {
|
||||
onRemoveFocus?.();
|
||||
});
|
||||
};
|
||||
|
||||
const renderIcon = () => {
|
||||
|
|
@ -76,23 +78,22 @@ export default function FavoriteItem({ item, type }: FavoriteItemProps) {
|
|||
const model = item as FavoriteModel;
|
||||
return (
|
||||
<div className="mr-2 h-5 w-5">
|
||||
<EndpointIcon
|
||||
conversation={{ endpoint: model.endpoint, model: model.model } as t.TConversation}
|
||||
endpoint={model.endpoint}
|
||||
model={model.model}
|
||||
size={20}
|
||||
/>
|
||||
<MinimalIcon endpoint={model.endpoint} size={20} isCreatedByUser={false} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getName = () => {
|
||||
const getName = (): string => {
|
||||
if (type === 'agent') {
|
||||
return (item as t.Agent).name;
|
||||
return (item as t.Agent).name ?? '';
|
||||
}
|
||||
return (item as FavoriteModel).model;
|
||||
};
|
||||
|
||||
const name = getName();
|
||||
const typeLabel = type === 'agent' ? localize('com_ui_agent') : localize('com_ui_model');
|
||||
const ariaLabel = `${name} (${typeLabel})`;
|
||||
|
||||
const menuId = React.useId();
|
||||
|
||||
const dropdownItems = [
|
||||
|
|
@ -105,22 +106,28 @@ export default function FavoriteItem({ item, type }: FavoriteItemProps) {
|
|||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={ariaLabel}
|
||||
className={cn(
|
||||
'group relative flex w-full cursor-pointer items-center justify-between rounded-lg px-3 py-2 text-sm text-text-primary hover:bg-surface-active-alt',
|
||||
isPopoverActive ? 'bg-surface-active-alt' : '',
|
||||
)}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
data-testid="favorite-item"
|
||||
>
|
||||
<div className="flex flex-1 items-center truncate pr-6">
|
||||
{renderIcon()}
|
||||
<span className="truncate">{getName()}</span>
|
||||
<span className="truncate">{name}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'absolute right-2 flex items-center',
|
||||
isPopoverActive ? 'opacity-100' : 'opacity-0 group-hover:opacity-100',
|
||||
isPopoverActive
|
||||
? 'opacity-100'
|
||||
: 'opacity-0 group-focus-within:opacity-100 group-hover:opacity-100',
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
|
|
@ -132,13 +139,23 @@ export default function FavoriteItem({ item, type }: FavoriteItemProps) {
|
|||
trigger={
|
||||
<Menu.MenuButton
|
||||
className={cn(
|
||||
'flex h-7 w-7 items-center justify-center rounded-md',
|
||||
isPopoverActive ? 'bg-surface-active-alt' : '',
|
||||
'inline-flex h-7 w-7 items-center justify-center rounded-md border-none p-0 text-sm font-medium ring-ring-primary transition-all duration-200 ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:opacity-50',
|
||||
isPopoverActive
|
||||
? 'opacity-100'
|
||||
: 'opacity-0 focus:opacity-100 group-focus-within:opacity-100 group-hover:opacity-100 data-[open]:opacity-100',
|
||||
)}
|
||||
aria-label={localize('com_ui_options')}
|
||||
aria-label={localize('com_nav_convo_menu_options')}
|
||||
data-testid="favorite-options-button"
|
||||
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onKeyDown={(e: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Ellipsis className="h-4 w-4 text-text-secondary" />
|
||||
<Ellipsis className="icon-md text-text-secondary" aria-hidden={true} />
|
||||
</Menu.MenuButton>
|
||||
}
|
||||
items={dropdownItems}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,10 @@ import { QueryKeys, dataService } from 'librechat-data-provider';
|
|||
import { useQueries, useQueryClient } from '@tanstack/react-query';
|
||||
import type { InfiniteData } from '@tanstack/react-query';
|
||||
import type t from 'librechat-data-provider';
|
||||
import { useFavorites, useLocalize, useShowMarketplace } from '~/hooks';
|
||||
import { useFavorites, useLocalize, useShowMarketplace, useNewConvo } from '~/hooks';
|
||||
import useSelectMention from '~/hooks/Input/useSelectMention';
|
||||
import { useGetEndpointsQuery } from '~/data-provider';
|
||||
import { useAssistantsMapContext } from '~/Providers';
|
||||
import FavoriteItem from './FavoriteItem';
|
||||
import store from '~/store';
|
||||
|
||||
|
|
@ -123,6 +126,23 @@ export default function FavoritesList({
|
|||
const { favorites, reorderFavorites, isLoading: isFavoritesLoading } = useFavorites();
|
||||
const showAgentMarketplace = useShowMarketplace();
|
||||
|
||||
const { newConversation } = useNewConvo();
|
||||
const assistantsMap = useAssistantsMapContext();
|
||||
const conversation = useRecoilValue(store.conversationByIndex(0));
|
||||
const { data: endpointsConfig = {} as t.TEndpointsConfig } = useGetEndpointsQuery();
|
||||
|
||||
const { onSelectEndpoint } = useSelectMention({
|
||||
modelSpecs: [],
|
||||
conversation,
|
||||
assistantsMap,
|
||||
endpointsConfig,
|
||||
newConversation,
|
||||
returnHandlers: true,
|
||||
});
|
||||
|
||||
const marketplaceRef = useRef<HTMLDivElement>(null);
|
||||
const listContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleAgentMarketplace = useCallback(() => {
|
||||
navigate('/agents');
|
||||
if (isSmallScreen && toggleNav) {
|
||||
|
|
@ -130,6 +150,24 @@ export default function FavoritesList({
|
|||
}
|
||||
}, [navigate, isSmallScreen, toggleNav]);
|
||||
|
||||
const handleRemoveFocus = useCallback(() => {
|
||||
if (marketplaceRef.current) {
|
||||
marketplaceRef.current.focus();
|
||||
return;
|
||||
}
|
||||
const nextFavorite = listContainerRef.current?.querySelector<HTMLElement>(
|
||||
'[data-testid="favorite-item"]',
|
||||
);
|
||||
if (nextFavorite) {
|
||||
nextFavorite.focus();
|
||||
return;
|
||||
}
|
||||
const newChatButton = document.querySelector<HTMLElement>(
|
||||
'[data-testid="nav-new-chat-button"]',
|
||||
);
|
||||
newChatButton?.focus();
|
||||
}, []);
|
||||
|
||||
// Ensure favorites is always an array (could be corrupted in localStorage)
|
||||
const safeFavorites = useMemo(() => (Array.isArray(favorites) ? favorites : []), [favorites]);
|
||||
|
||||
|
|
@ -228,7 +266,7 @@ export default function FavoritesList({
|
|||
|
||||
return (
|
||||
<div className="mb-2 flex flex-col">
|
||||
<div className="mt-1 flex flex-col gap-1">
|
||||
<div ref={listContainerRef} className="mt-1 flex flex-col gap-1">
|
||||
{/* Show skeletons for ALL items while agents are still loading */}
|
||||
{isAgentsLoading ? (
|
||||
<>
|
||||
|
|
@ -244,8 +282,18 @@ export default function FavoritesList({
|
|||
{/* Agent Marketplace button */}
|
||||
{showAgentMarketplace && (
|
||||
<div
|
||||
ref={marketplaceRef}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={localize('com_agents_marketplace')}
|
||||
className="group relative flex w-full cursor-pointer items-center justify-between rounded-lg px-3 py-2 text-sm text-text-primary hover:bg-surface-active-alt"
|
||||
onClick={handleAgentMarketplace}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleAgentMarketplace();
|
||||
}
|
||||
}}
|
||||
data-testid="nav-agents-marketplace-button"
|
||||
>
|
||||
<div className="flex flex-1 items-center truncate pr-6">
|
||||
|
|
@ -270,7 +318,12 @@ export default function FavoritesList({
|
|||
moveItem={moveItem}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<FavoriteItem item={agent} type="agent" />
|
||||
<FavoriteItem
|
||||
item={agent}
|
||||
type="agent"
|
||||
onSelectEndpoint={onSelectEndpoint}
|
||||
onRemoveFocus={handleRemoveFocus}
|
||||
/>
|
||||
</DraggableFavoriteItem>
|
||||
);
|
||||
} else if (fav.model && fav.endpoint) {
|
||||
|
|
@ -285,6 +338,8 @@ export default function FavoritesList({
|
|||
<FavoriteItem
|
||||
item={{ model: fav.model, endpoint: fav.endpoint }}
|
||||
type="model"
|
||||
onSelectEndpoint={onSelectEndpoint}
|
||||
onRemoveFocus={handleRemoveFocus}
|
||||
/>
|
||||
</DraggableFavoriteItem>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue