LibreChat/client/src/components/Chat/Menus/Endpoints/components/EndpointItem.tsx
Dustin Healy 9d612715a5
️ feat: Accessible Model Selection Icons and Announcements (#11454)
* feat: more accessible model selection ui and announcements

* chore: formatting
2026-01-21 13:53:10 -05:00

253 lines
8.6 KiB
TypeScript

import { useMemo } from 'react';
import { VisuallyHidden } from '@ariakit/react';
import { Spinner, TooltipAnchor } from '@librechat/client';
import { CheckCircle2, MousePointerClick, SettingsIcon } from 'lucide-react';
import { EModelEndpoint, isAgentsEndpoint, isAssistantsEndpoint } from 'librechat-data-provider';
import type { TModelSpec } from 'librechat-data-provider';
import type { Endpoint } from '~/common';
import { CustomMenu as Menu, CustomMenuItem as MenuItem } from '../CustomMenu';
import { useModelSelectorContext } from '../ModelSelectorContext';
import { renderEndpointModels } from './EndpointModelItem';
import { ModelSpecItem } from './ModelSpecItem';
import { filterModels } from '../utils';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
interface EndpointItemProps {
endpoint: Endpoint;
endpointIndex: number;
}
const SettingsButton = ({
endpoint,
className,
handleOpenKeyDialog,
}: {
endpoint: Endpoint;
className?: string;
handleOpenKeyDialog: (endpoint: EModelEndpoint, e: React.MouseEvent) => void;
}) => {
const localize = useLocalize();
const text = localize('com_endpoint_config_key');
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
if (!endpoint.value) {
return;
}
e.stopPropagation();
handleOpenKeyDialog(endpoint.value as EModelEndpoint, e);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
if (endpoint.value) {
handleOpenKeyDialog(endpoint.value as EModelEndpoint, e as unknown as React.MouseEvent);
}
}
};
return (
<button
type="button"
id={`endpoint-${endpoint.value}-settings`}
onClick={handleClick}
onKeyDown={handleKeyDown}
className={cn(
'group/button flex items-center gap-1.5 rounded-md px-1.5',
'text-text-secondary transition-colors duration-150',
'hover:bg-surface-tertiary hover:text-text-primary',
'focus-visible:bg-surface-tertiary focus-visible:text-text-primary',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1',
className,
)}
aria-label={`${text} ${endpoint.label}`}
>
<SettingsIcon className="size-4 shrink-0" aria-hidden="true" />
<span
aria-hidden="true"
className={cn(
'grid overflow-hidden transition-[grid-template-columns,opacity] duration-150 ease-out',
'grid-cols-[0fr] opacity-0',
'group-hover/button:grid-cols-[1fr] group-hover/button:opacity-100',
'group-focus-visible/button:grid-cols-[1fr] group-focus-visible/button:opacity-100',
)}
>
<span className="min-w-0 truncate pr-0.5">{text}</span>
</span>
</button>
);
};
export function EndpointItem({ endpoint, endpointIndex }: EndpointItemProps) {
const localize = useLocalize();
const {
agentsMap,
assistantsMap,
modelSpecs,
selectedValues,
handleOpenKeyDialog,
handleSelectEndpoint,
endpointSearchValues,
setEndpointSearchValue,
endpointRequiresUserKey,
} = useModelSelectorContext();
const {
model: selectedModel,
endpoint: selectedEndpoint,
modelSpec: selectedSpec,
} = selectedValues;
// Filter modelSpecs for this endpoint (by group matching endpoint value)
const endpointSpecs = useMemo(() => {
if (!modelSpecs || !modelSpecs.length) {
return [];
}
return modelSpecs.filter((spec: TModelSpec) => spec.group === endpoint.value);
}, [modelSpecs, endpoint.value]);
const searchValue = endpointSearchValues[endpoint.value] || '';
const isUserProvided = useMemo(
() => endpointRequiresUserKey(endpoint.value),
[endpointRequiresUserKey, endpoint.value],
);
const isAssistantsNotLoaded =
isAssistantsEndpoint(endpoint.value) && endpoint.models === undefined;
const renderIconLabel = () => (
<div className="flex min-w-0 items-center gap-2">
{endpoint.icon && (
<div className="flex shrink-0 items-center justify-center" aria-hidden="true">
{endpoint.icon}
</div>
)}
<span className="truncate text-left">{endpoint.label}</span>
</div>
);
const isEndpointSelected = selectedEndpoint === endpoint.value;
if (endpoint.hasModels) {
const filteredModels = searchValue
? filterModels(
endpoint,
(endpoint.models || []).map((model) => model.name),
searchValue,
agentsMap,
assistantsMap,
)
: null;
const placeholder =
isAgentsEndpoint(endpoint.value) || isAssistantsEndpoint(endpoint.value)
? localize('com_endpoint_search_var', { 0: endpoint.label })
: localize('com_endpoint_search_endpoint_models', { 0: endpoint.label });
return (
<Menu
id={`endpoint-${endpoint.value}-menu`}
key={`endpoint-${endpoint.value}-item`}
defaultOpen={endpoint.value === selectedEndpoint}
searchValue={searchValue}
onSearch={(value) => setEndpointSearchValue(endpoint.value, value)}
combobox={<input placeholder=" " />}
comboboxLabel={placeholder}
onClick={() => handleSelectEndpoint(endpoint)}
label={
<div className="group flex w-full min-w-0 items-center justify-between gap-1.5 py-1 text-sm">
{renderIconLabel()}
<div className="flex shrink-0 items-center gap-1">
{isUserProvided && (
<SettingsButton endpoint={endpoint} handleOpenKeyDialog={handleOpenKeyDialog} />
)}
{isEndpointSelected && (
<>
<CheckCircle2 className="size-4 shrink-0 text-text-primary" aria-hidden="true" />
<VisuallyHidden>{localize('com_a11y_selected')}</VisuallyHidden>
</>
)}
</div>
</div>
}
>
{isAssistantsEndpoint(endpoint.value) && endpoint.models === undefined ? (
<div
className="flex items-center justify-center p-2"
role="status"
aria-label={localize('com_ui_loading')}
>
<Spinner aria-hidden="true" />
</div>
) : (
<>
{/* Render modelSpecs for this endpoint */}
{endpointSpecs.map((spec: TModelSpec) => (
<ModelSpecItem key={spec.name} spec={spec} isSelected={selectedSpec === spec.name} />
))}
{/* Render endpoint models */}
{filteredModels
? renderEndpointModels(
endpoint,
endpoint.models || [],
selectedModel,
filteredModels,
endpointIndex,
)
: endpoint.models &&
renderEndpointModels(
endpoint,
endpoint.models,
selectedModel,
undefined,
endpointIndex,
)}
</>
)}
</Menu>
);
} else {
return (
<MenuItem
id={`endpoint-${endpoint.value}-menu`}
key={`endpoint-${endpoint.value}-item`}
onClick={() => handleSelectEndpoint(endpoint)}
aria-selected={isEndpointSelected || undefined}
className="group flex w-full cursor-pointer items-center justify-between gap-1.5 py-2 text-sm"
>
{renderIconLabel()}
<div className="flex shrink-0 items-center gap-2">
{endpointRequiresUserKey(endpoint.value) && (
<SettingsButton endpoint={endpoint} handleOpenKeyDialog={handleOpenKeyDialog} />
)}
{isAssistantsNotLoaded && (
<TooltipAnchor
description={localize('com_ui_click_to_view_var', { 0: endpoint.label })}
side="top"
render={
<span className="flex items-center">
<MousePointerClick className="size-4 text-text-secondary" aria-hidden="true" />
</span>
}
/>
)}
{isEndpointSelected && !isAssistantsNotLoaded && (
<>
<CheckCircle2 className="size-4 shrink-0 text-text-primary" aria-hidden="true" />
<VisuallyHidden>{localize('com_a11y_selected')}</VisuallyHidden>
</>
)}
</div>
</MenuItem>
);
}
}
export function renderEndpoints(mappedEndpoints: Endpoint[]) {
return mappedEndpoints.map((endpoint, index) => (
<EndpointItem
endpoint={endpoint}
endpointIndex={index}
key={`endpoint-${endpoint.value}-${index}`}
/>
));
}