mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-02-14 14:38:11 +01:00
🪟 style: Agent Marketplace UI Responsiveness, a11y, and Navigation (#9068)
* refactor: Agent Marketplace Button with access control * fix(agent-marketplace): update marketplace UI and access control * fix(agent-card): handle optional agent description for accessibility * fix(agent-card): remove unnecessary icon checks from tests * chore: remove unused keys --------- Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
parent
c78fd0fc83
commit
4ec7bcb60f
17 changed files with 164 additions and 162 deletions
|
|
@ -28,7 +28,7 @@ const AgentCard: React.FC<AgentCardProps> = ({ agent, onClick, className = '' })
|
|||
onClick={onClick}
|
||||
aria-label={localize('com_agents_agent_card_label', {
|
||||
name: agent.name,
|
||||
description: agent.description || localize('com_agents_no_description'),
|
||||
description: agent.description ?? '',
|
||||
})}
|
||||
aria-describedby={`agent-${agent.id}-description`}
|
||||
tabIndex={0}
|
||||
|
|
@ -47,50 +47,47 @@ const AgentCard: React.FC<AgentCardProps> = ({ agent, onClick, className = '' })
|
|||
<div className="flex-shrink-0">{renderAgentAvatar(agent, { size: 'sm' })}</div>
|
||||
|
||||
{/* Category tag */}
|
||||
<div className="inline-flex items-center rounded-md border-border-xheavy bg-surface-active-alt px-2 py-1 text-xs font-medium">
|
||||
{agent.category && (
|
||||
{agent.category && (
|
||||
<div className="inline-flex items-center rounded-md border-border-xheavy bg-surface-active-alt px-2 py-1 text-xs font-medium">
|
||||
<Label className="line-clamp-1 font-normal">
|
||||
{agent.category.charAt(0).toUpperCase() + agent.category.slice(1)}
|
||||
</Label>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right column: Name, description, and other content */}
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex h-full min-w-0 flex-1 flex-col justify-between space-y-1">
|
||||
<div className="space-y-1">
|
||||
{/* Agent name */}
|
||||
<Label className="mb-1 line-clamp-1 text-xl font-semibold text-text-primary">
|
||||
{agent.name}
|
||||
</Label>
|
||||
|
||||
{/* Owner info */}
|
||||
{(() => {
|
||||
const displayName = getContactDisplayName(agent);
|
||||
if (displayName) {
|
||||
return (
|
||||
<div className="flex items-center text-sm text-text-secondary">
|
||||
<Label className="mr-1">🔹</Label>
|
||||
<Label>{displayName}</Label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
{/* Agent description */}
|
||||
<p
|
||||
id={`agent-${agent.id}-description`}
|
||||
className="line-clamp-3 text-sm leading-relaxed text-text-primary"
|
||||
{...(agent.description ? { 'aria-label': `Description: ${agent.description}` } : {})}
|
||||
>
|
||||
{agent.description ?? ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Agent description */}
|
||||
<p
|
||||
id={`agent-${agent.id}-description`}
|
||||
className="line-clamp-3 text-sm leading-relaxed text-text-primary"
|
||||
aria-label={`Description: ${agent.description || localize('com_agents_no_description')}`}
|
||||
>
|
||||
{agent.description || (
|
||||
<Label className="font-normal italic text-text-primary">
|
||||
{localize('com_agents_no_description')}
|
||||
</Label>
|
||||
)}
|
||||
</p>
|
||||
{/* Owner info - moved to bottom right */}
|
||||
{(() => {
|
||||
const displayName = getContactDisplayName(agent);
|
||||
if (displayName) {
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<div className="flex items-center text-sm text-text-secondary">
|
||||
<Label>{displayName}</Label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -126,10 +126,7 @@ const AgentDetail: React.FC<AgentDetailProps> = ({ agent, isOpen, onClose }) =>
|
|||
|
||||
return (
|
||||
<OGDialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<OGDialogContent
|
||||
ref={dialogRef}
|
||||
className="max-h-[90vh] overflow-y-auto py-8 sm:max-w-[450px]"
|
||||
>
|
||||
<OGDialogContent ref={dialogRef} className="max-h-[90vh] w-11/12 max-w-lg overflow-y-auto">
|
||||
{/* Copy link button - positioned next to close button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
|
@ -161,11 +158,7 @@ const AgentDetail: React.FC<AgentDetailProps> = ({ agent, isOpen, onClose }) =>
|
|||
|
||||
{/* Agent description - below contact */}
|
||||
<div className="mt-4 whitespace-pre-wrap px-6 text-center text-base text-text-primary">
|
||||
{agent?.description || (
|
||||
<span className="italic text-text-tertiary">
|
||||
{localize('com_agents_no_description')}
|
||||
</span>
|
||||
)}
|
||||
{agent?.description}
|
||||
</div>
|
||||
|
||||
{/* Action button */}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import React from 'react';
|
||||
import type t from 'librechat-data-provider';
|
||||
import useLocalize from '~/hooks/useLocalize';
|
||||
import { useMediaQuery } from '@librechat/client';
|
||||
import { SmartLoader } from './SmartLoader';
|
||||
import { useLocalize } from '~/hooks/';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
/**
|
||||
|
|
@ -33,6 +34,7 @@ const CategoryTabs: React.FC<CategoryTabsProps> = ({
|
|||
onChange,
|
||||
}) => {
|
||||
const localize = useLocalize();
|
||||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||
|
||||
// Helper function to get category display name from database data
|
||||
const getCategoryDisplayName = (category: t.TCategory) => {
|
||||
|
|
@ -120,10 +122,24 @@ const CategoryTabs: React.FC<CategoryTabsProps> = ({
|
|||
const tabsContent = (
|
||||
<div className="w-full pb-2">
|
||||
<div
|
||||
className="flex flex-wrap justify-center gap-1.5 px-4"
|
||||
className={cn(
|
||||
'px-4',
|
||||
isSmallScreen
|
||||
? 'scrollbar-hide flex gap-2 overflow-x-auto scroll-smooth'
|
||||
: 'flex flex-wrap justify-center gap-1.5',
|
||||
)}
|
||||
role="tablist"
|
||||
aria-label={localize('com_agents_category_tabs_label')}
|
||||
aria-orientation="horizontal"
|
||||
style={
|
||||
isSmallScreen
|
||||
? {
|
||||
scrollbarWidth: 'none',
|
||||
msOverflowStyle: 'none',
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{categories.map((category, index) => (
|
||||
<button
|
||||
|
|
@ -132,10 +148,11 @@ const CategoryTabs: React.FC<CategoryTabsProps> = ({
|
|||
onClick={() => onChange(category.value)}
|
||||
onKeyDown={(e) => handleKeyDown(e, category.value)}
|
||||
className={cn(
|
||||
'relative cursor-pointer select-none whitespace-nowrap px-3 py-2 transition-colors',
|
||||
'relative cursor-pointer select-none whitespace-nowrap px-3 py-2 transition-all duration-200',
|
||||
isSmallScreen ? 'min-w-fit flex-shrink-0' : '',
|
||||
activeTab === category.value
|
||||
? 'rounded-t-lg bg-surface-hover text-text-primary'
|
||||
: 'rounded-lg bg-surface-secondary text-text-secondary hover:bg-surface-hover hover:text-text-primary',
|
||||
: 'rounded-lg bg-surface-secondary text-text-secondary hover:bg-surface-hover hover:text-text-primary active:scale-95',
|
||||
)}
|
||||
role="tab"
|
||||
aria-selected={activeTab === category.value}
|
||||
|
|
|
|||
|
|
@ -285,10 +285,6 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
|
|||
ref={scrollContainerRef}
|
||||
className="scrollbar-gutter-stable relative flex h-full flex-col overflow-y-auto overflow-x-hidden"
|
||||
>
|
||||
{/* Admin Settings */}
|
||||
<div className="absolute right-4 top-4 z-30">
|
||||
<MarketplaceAdminSettings />
|
||||
</div>
|
||||
{/* Simplified header for agents marketplace - only show nav controls when needed */}
|
||||
{!isSmallScreen && (
|
||||
<div className="sticky top-0 z-20 flex items-center justify-between bg-surface-secondary p-2 font-semibold text-text-primary md:h-14">
|
||||
|
|
@ -319,19 +315,19 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hero Section - scrolls away */}
|
||||
<div className="container mx-auto max-w-4xl">
|
||||
<div className={cn('mb-8 text-center', isSmallScreen ? 'mt-6' : 'mt-12')}>
|
||||
<h1 className="mb-3 text-3xl font-bold tracking-tight text-text-primary md:text-5xl">
|
||||
{localize('com_agents_marketplace')}
|
||||
</h1>
|
||||
<p className="mx-auto mb-6 max-w-2xl text-lg text-text-secondary">
|
||||
{localize('com_agents_marketplace_subtitle')}
|
||||
</p>
|
||||
{!isSmallScreen && (
|
||||
<div className="container mx-auto max-w-4xl">
|
||||
<div className={cn('mb-8 text-center', 'mt-12')}>
|
||||
<h1 className="mb-3 text-3xl font-bold tracking-tight text-text-primary md:text-5xl">
|
||||
{localize('com_agents_marketplace')}
|
||||
</h1>
|
||||
<p className="mx-auto mb-6 max-w-2xl text-lg text-text-secondary">
|
||||
{localize('com_agents_marketplace_subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
)}
|
||||
{/* Sticky wrapper for search bar and categories */}
|
||||
<div
|
||||
className={cn(
|
||||
|
|
@ -341,8 +337,11 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
|
|||
>
|
||||
<div className="container mx-auto max-w-4xl px-4">
|
||||
{/* Search bar */}
|
||||
<div className="mx-auto max-w-2xl pb-6">
|
||||
<div className="mx-auto flex max-w-2xl gap-2 pb-6">
|
||||
<SearchBar value={searchQuery} onSearch={handleSearch} />
|
||||
{/* TODO: Remove this once we have a better way to handle admin settings */}
|
||||
{/* Admin Settings */}
|
||||
<MarketplaceAdminSettings />
|
||||
</div>
|
||||
|
||||
{/* Category tabs */}
|
||||
|
|
@ -354,7 +353,6 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable content area */}
|
||||
<div className="container mx-auto max-w-4xl px-4 pb-8">
|
||||
{/* Two-pane animated container wrapping category header + grid */}
|
||||
|
|
@ -510,7 +508,6 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
|
|||
{/* Note: Using Tailwind keyframes for slide in/out animations */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Agent detail dialog */}
|
||||
{isDetailOpen && selectedAgent && (
|
||||
<AgentDetail
|
||||
|
|
|
|||
|
|
@ -146,14 +146,13 @@ const MarketplaceAdminSettings = () => {
|
|||
<OGDialog>
|
||||
<OGDialogTrigger asChild>
|
||||
<Button
|
||||
variant={'outline'}
|
||||
className="btn btn-neutral border-token-border-light relative gap-1 rounded-lg font-medium"
|
||||
variant="outline"
|
||||
className="relative h-12 rounded-xl border-border-medium font-medium"
|
||||
>
|
||||
<ShieldEllipsis className="cursor-pointer" aria-hidden="true" />
|
||||
{localize('com_ui_admin_settings')}
|
||||
</Button>
|
||||
</OGDialogTrigger>
|
||||
<OGDialogContent className="w-full border-border-light bg-surface-primary text-text-primary md:w-1/4">
|
||||
<OGDialogContent className="w-11/12 max-w-md border-border-light bg-surface-primary text-text-primary">
|
||||
<OGDialogTitle>{`${localize('com_ui_admin_settings')} - ${localize(
|
||||
'com_ui_marketplace',
|
||||
)}`}</OGDialogTitle>
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ const SearchBar: React.FC<SearchBarProps> = ({ value, onSearch, className = '' }
|
|||
value={searchTerm}
|
||||
onChange={handleChange}
|
||||
placeholder={localize('com_agents_search_placeholder')}
|
||||
className="h-14 rounded-2xl border-border-medium bg-transparent pl-12 pr-12 text-lg text-text-primary shadow-md transition-[border-color,box-shadow] duration-200 placeholder:text-text-secondary focus:border-border-heavy focus:shadow-lg focus:ring-0"
|
||||
className="h-12 rounded-xl border-border-medium bg-transparent pl-12 pr-12 text-lg text-text-primary shadow-md transition-[border-color,box-shadow] duration-200 placeholder:text-text-secondary focus:border-border-heavy focus:shadow-lg focus:ring-0"
|
||||
aria-label={localize('com_agents_search_aria')}
|
||||
aria-describedby="search-instructions search-results-count"
|
||||
autoComplete="off"
|
||||
|
|
|
|||
|
|
@ -53,7 +53,6 @@ const mockLocalize = jest.fn((key: string, options?: any) => {
|
|||
com_agents_search_placeholder: 'Search agents...',
|
||||
com_agents_clear_search: 'Clear search',
|
||||
com_agents_agent_card_label: `${options?.name} agent. ${options?.description}`,
|
||||
com_agents_no_description: 'No description available',
|
||||
com_agents_grid_announcement: `Showing ${options?.count} agents in ${options?.category} category`,
|
||||
com_agents_load_more_label: `Load more agents from ${options?.category} category`,
|
||||
com_agents_error_retry: 'Try Again',
|
||||
|
|
@ -307,13 +306,6 @@ describe('Accessibility Improvements', () => {
|
|||
expect(card).toHaveAttribute('role', 'button');
|
||||
});
|
||||
|
||||
it('handles agents without descriptions', () => {
|
||||
const agentWithoutDesc = { ...mockAgent, description: undefined };
|
||||
render(<AgentCard agent={agentWithoutDesc as any as t.Agent} onClick={jest.fn()} />);
|
||||
|
||||
expect(screen.getByText('No description available')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('supports keyboard interaction', () => {
|
||||
const onClick = jest.fn();
|
||||
render(<AgentCard agent={mockAgent as t.Agent} onClick={onClick} />);
|
||||
|
|
|
|||
|
|
@ -48,7 +48,6 @@ describe('AgentCard', () => {
|
|||
|
||||
expect(screen.getByText('Test Agent')).toBeInTheDocument();
|
||||
expect(screen.getByText('A test agent for testing purposes')).toBeInTheDocument();
|
||||
expect(screen.getByText('🔹')).toBeInTheDocument();
|
||||
expect(screen.getByText('Test Support')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
|
@ -152,7 +151,6 @@ describe('AgentCard', () => {
|
|||
|
||||
render(<AgentCard agent={agentWithAuthorName} onClick={mockOnClick} />);
|
||||
|
||||
expect(screen.getByText('🔹')).toBeInTheDocument();
|
||||
expect(screen.getByText('John Doe')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
|
@ -165,7 +163,6 @@ describe('AgentCard', () => {
|
|||
|
||||
render(<AgentCard agent={agentWithEmailOnly} onClick={mockOnClick} />);
|
||||
|
||||
expect(screen.getByText('🔹')).toBeInTheDocument();
|
||||
expect(screen.getByText('contact@example.com')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
|
@ -178,7 +175,6 @@ describe('AgentCard', () => {
|
|||
|
||||
render(<AgentCard agent={agentWithBoth} onClick={mockOnClick} />);
|
||||
|
||||
expect(screen.getByText('🔹')).toBeInTheDocument();
|
||||
expect(screen.getByText('Support Team')).toBeInTheDocument();
|
||||
expect(screen.queryByText('John Doe')).not.toBeInTheDocument();
|
||||
});
|
||||
|
|
@ -195,7 +191,6 @@ describe('AgentCard', () => {
|
|||
|
||||
render(<AgentCard agent={agentWithNameAndEmail} onClick={mockOnClick} />);
|
||||
|
||||
expect(screen.getByText('🔹')).toBeInTheDocument();
|
||||
expect(screen.getByText('Support Team')).toBeInTheDocument();
|
||||
expect(screen.queryByText('support@example.com')).not.toBeInTheDocument();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -179,7 +179,6 @@ describe('AgentDetail', () => {
|
|||
renderWithProviders(<AgentDetail {...defaultProps} agent={null as any} />);
|
||||
|
||||
expect(screen.getByText('com_agents_loading')).toBeInTheDocument();
|
||||
expect(screen.getByText('com_agents_no_description')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render copy link button', () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue