style(MCP): Enhance dialog accessibility and styling consistency (#11585)

* style: update input IDs in BasicInfoSection for consistency and improve accessibility

* style: add border-destructive variable for improved design consistency

* style: update error border color for title input in BasicInfoSection

* style: update delete confirmation dialog title and description for MCP Server

* style: add text-destructive variable for improved design consistency

* style: update error message and border color for URL and trust fields for consistency

* style: reorder imports and update error message styling for consistency across sections

* style: enhance MCPServerDialog with copy link functionality and UI improvements

* style: enhance MCPServerDialog with improved accessibility and loading indicators

* style: bump @librechat/client to 0.4.51 and enhance OGDialogTemplate for improved selection handling

* a11y: enhance accessibility and error handling in MCPServerDialog sections

* style: enhance MCPServerDialog accessibility and improve resource name handling

* style: improve accessibility in MCPServerDialog and AuthSection, update translation for delete confirmation

* style: update aria-invalid attributes to use string values for improved accessibility in form sections

* style: enhance accessibility in AuthSection by updating aria attributes and adding error messages

* style: remove unnecessary aria-hidden attributes from Spinner components in MCPServerDialog

* style: simplify legacy selection check in OGDialogTemplate
This commit is contained in:
Marco Beretta 2026-02-12 04:08:40 +01:00 committed by GitHub
parent 299efc2ccb
commit d6b6f191f7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 295 additions and 141 deletions

View file

@ -1,10 +1,10 @@
import { FormProvider } from 'react-hook-form';
import type { useMCPServerForm } from './hooks/useMCPServerForm';
import ConnectionSection from './sections/ConnectionSection';
import BasicInfoSection from './sections/BasicInfoSection';
import TransportSection from './sections/TransportSection';
import AuthSection from './sections/AuthSection';
import TrustSection from './sections/TrustSection';
import type { useMCPServerForm } from './hooks/useMCPServerForm';
import AuthSection from './sections/AuthSection';
interface MCPServerFormProps {
formHook: ReturnType<typeof useMCPServerForm>;

View file

@ -1,13 +1,18 @@
import React, { useState, useEffect } from 'react';
import { Copy, CopyCheck } from 'lucide-react';
import {
OGDialog,
OGDialogTemplate,
OGDialogContent,
OGDialogHeader,
OGDialogTitle,
Label,
Input,
Button,
TrashIcon,
Spinner,
TrashIcon,
useToastContext,
OGDialog,
OGDialogTitle,
OGDialogHeader,
OGDialogFooter,
OGDialogContent,
OGDialogTemplate,
} from '@librechat/client';
import {
SystemRoles,
@ -16,10 +21,10 @@ import {
PermissionBits,
PermissionTypes,
} from 'librechat-data-provider';
import { GenericGrantAccessDialog } from '~/components/Sharing';
import { useAuthContext, useHasAccess, useResourcePermissions, MCPServerDefinition } from '~/hooks';
import { useLocalize } from '~/hooks';
import { GenericGrantAccessDialog } from '~/components/Sharing';
import { useMCPServerForm } from './hooks/useMCPServerForm';
import { useLocalize, useCopyToClipboard } from '~/hooks';
import MCPServerForm from './MCPServerForm';
interface MCPServerDialogProps {
@ -39,8 +44,10 @@ export default function MCPServerDialog({
}: MCPServerDialogProps) {
const localize = useLocalize();
const { user } = useAuthContext();
const { showToast } = useToastContext();
// State for dialogs
const [isCopying, setIsCopying] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showRedirectUriDialog, setShowRedirectUriDialog] = useState(false);
const [createdServerId, setCreatedServerId] = useState<string | null>(null);
@ -99,20 +106,26 @@ export default function MCPServerDialog({
? `${window.location.origin}/api/mcp/${createdServerId}/oauth/callback`
: '';
const copyLink = useCopyToClipboard({ text: redirectUri });
return (
<>
{/* Delete confirmation dialog */}
<OGDialog open={showDeleteConfirm} onOpenChange={(isOpen) => setShowDeleteConfirm(isOpen)}>
<OGDialogTemplate
title={localize('com_ui_delete')}
className="max-w-[450px]"
main={<p className="text-left text-sm">{localize('com_ui_mcp_server_delete_confirm')}</p>}
selection={{
selectHandler: handleDelete,
selectClasses:
'bg-destructive text-white transition-all duration-200 hover:bg-destructive/80',
selectText: isDeleting ? <Spinner /> : localize('com_ui_delete'),
}}
title={localize('com_ui_delete_mcp_server')}
className="w-11/12 max-w-md"
description={localize('com_ui_mcp_server_delete_confirm', { 0: server?.serverName })}
selection={
<Button
onClick={handleDelete}
variant="destructive"
aria-live="polite"
aria-label={isDeleting ? localize('com_ui_deleting') : localize('com_ui_delete')}
>
{isDeleting ? <Spinner /> : localize('com_ui_delete')}
</Button>
}
/>
</OGDialog>
@ -127,48 +140,53 @@ export default function MCPServerDialog({
}
}}
>
<OGDialogContent className="w-full max-w-lg border-none bg-surface-primary text-text-primary">
<OGDialogHeader className="border-b border-border-light px-4 py-3">
<OGDialogContent showCloseButton={false} className="w-11/12 max-w-lg">
<OGDialogHeader>
<OGDialogTitle>{localize('com_ui_mcp_server_created')}</OGDialogTitle>
</OGDialogHeader>
<div className="space-y-4 p-4">
<p className="text-sm text-text-secondary">
{localize('com_ui_redirect_uri_instructions')}
</p>
<div className="rounded-lg border border-border-medium bg-surface-secondary p-3">
<label className="mb-2 block text-xs font-medium text-text-secondary">
<div className="space-y-4">
<p className="text-sm">{localize('com_ui_redirect_uri_instructions')}</p>
<div className="space-y-2">
<Label htmlFor="redirect-uri-input" className="text-sm font-medium">
{localize('com_ui_redirect_uri')}
</label>
</Label>
<div className="flex items-center gap-2">
<input
className="flex-1 rounded border border-border-medium bg-surface-primary px-3 py-2 text-sm"
value={redirectUri}
<Input
id="redirect-uri-input"
type="text"
readOnly
value={redirectUri}
className="flex-1 text-text-secondary"
/>
<Button
onClick={() => {
navigator.clipboard.writeText(redirectUri);
}}
size="icon"
variant="outline"
className="whitespace-nowrap"
onClick={() => {
if (isCopying) return;
showToast({ message: localize('com_ui_copied_to_clipboard') });
copyLink(setIsCopying);
}}
disabled={isCopying}
className="p-0"
aria-label={localize('com_ui_copy_link')}
>
{localize('com_ui_copy_link')}
{isCopying ? <CopyCheck className="size-4" /> : <Copy className="size-4" />}
</Button>
</div>
</div>
<div className="flex justify-end">
<OGDialogFooter>
<Button
variant="default"
onClick={() => {
setShowRedirectUriDialog(false);
onOpenChange(false);
setCreatedServerId(null);
}}
variant="submit"
className="text-white"
>
{localize('com_ui_done')}
</Button>
</div>
</OGDialogFooter>
</div>
</OGDialogContent>
</OGDialog>
@ -187,6 +205,7 @@ export default function MCPServerDialog({
})
: undefined
}
showCloseButton={false}
className="w-11/12 md:max-w-3xl"
main={<MCPServerForm formHook={formHook} />}
footerClassName="sm:justify-between"
@ -194,16 +213,15 @@ export default function MCPServerDialog({
isEditMode ? (
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
variant="destructive"
size="sm"
aria-label={localize('com_ui_delete')}
aria-label={localize('com_ui_delete_mcp_server_name', {
0: server?.config?.title || server?.serverName || '',
})}
onClick={() => setShowDeleteConfirm(true)}
disabled={isSubmitting || isDeleting}
>
<div className="flex w-full items-center justify-center gap-2 text-red-500">
<TrashIcon />
</div>
<TrashIcon aria-hidden="true" />
</Button>
{shouldShowShareButton && server && (
<GenericGrantAccessDialog
@ -218,10 +236,15 @@ export default function MCPServerDialog({
buttons={
<Button
type="button"
variant="submit"
variant={isEditMode ? 'default' : 'submit'}
onClick={onSubmit}
disabled={isSubmitting}
className="text-white"
aria-live="polite"
aria-label={
isSubmitting
? localize(isEditMode ? 'com_ui_updating' : 'com_ui_creating')
: localize(isEditMode ? 'com_ui_update_mcp_server' : 'com_ui_create_mcp_server')
}
>
{isSubmitting ? (
<Spinner className="size-4" />

View file

@ -1,11 +1,11 @@
import { useMemo, useState } from 'react';
import { Copy, CopyCheck } from 'lucide-react';
import { useFormContext, useWatch } from 'react-hook-form';
import { Label, Input, Checkbox, SecretInput, Radio, useToastContext } from '@librechat/client';
import { Copy, CopyCheck } from 'lucide-react';
import { useLocalize, useCopyToClipboard } from '~/hooks';
import { cn } from '~/utils';
import { AuthTypeEnum, AuthorizationTypeEnum } from '../hooks/useMCPServerForm';
import type { MCPServerFormData } from '../hooks/useMCPServerForm';
import { useLocalize, useCopyToClipboard } from '~/hooks';
import { cn } from '~/utils';
interface AuthSectionProps {
isEditMode: boolean;
@ -62,15 +62,20 @@ export default function AuthSection({ isEditMode, serverName }: AuthSectionProps
return (
<div className="space-y-3">
{/* Auth Type Radio */}
<div className="space-y-1.5">
<Label className="text-sm font-medium">{localize('com_ui_authentication')}</Label>
<fieldset className="space-y-1.5">
<legend>
<Label id="auth-type-label" className="text-sm font-medium">
{localize('com_ui_authentication')}
</Label>
</legend>
<Radio
options={authTypeOptions}
value={authType || AuthTypeEnum.None}
onChange={(val) => setValue('auth.auth_type', val as AuthTypeEnum)}
fullWidth
aria-labelledby="auth-type-label"
/>
</div>
</fieldset>
{/* API Key Fields */}
{authType === AuthTypeEnum.ServiceHttp && (
@ -83,9 +88,13 @@ export default function AuthSection({ isEditMode, serverName }: AuthSectionProps
onCheckedChange={(checked) =>
setValue('auth.api_key_source', checked ? 'user' : 'admin')
}
aria-label={localize('com_ui_user_provides_key')}
aria-labelledby="user_provides_key_label"
/>
<label htmlFor="user_provides_key" className="cursor-pointer text-sm">
<label
id="user_provides_key_label"
htmlFor="user_provides_key"
className="cursor-pointer text-sm"
>
{localize('com_ui_user_provides_key')}
</label>
</div>
@ -101,8 +110,12 @@ export default function AuthSection({ isEditMode, serverName }: AuthSectionProps
)}
{/* Header Format Radio */}
<div className="space-y-1.5">
<Label className="text-sm font-medium">{localize('com_ui_header_format')}</Label>
<fieldset className="space-y-1.5">
<legend>
<Label id="header-format-label" className="text-sm font-medium">
{localize('com_ui_header_format')}
</Label>
</legend>
<Radio
options={headerFormatOptions}
value={authorizationType || AuthorizationTypeEnum.Bearer}
@ -110,8 +123,9 @@ export default function AuthSection({ isEditMode, serverName }: AuthSectionProps
setValue('auth.api_key_authorization_type', val as AuthorizationTypeEnum)
}
fullWidth
aria-labelledby="header-format-label"
/>
</div>
</fieldset>
{/* Custom header name */}
{authorizationType === AuthorizationTypeEnum.Custom && (
@ -137,27 +151,67 @@ export default function AuthSection({ isEditMode, serverName }: AuthSectionProps
<div className="space-y-1.5">
<Label htmlFor="oauth_client_id" className="text-sm font-medium">
{localize('com_ui_client_id')}{' '}
{!isEditMode && <span className="text-text-secondary">*</span>}
{!isEditMode && (
<>
<span aria-hidden="true" className="text-text-secondary">
*
</span>
<span className="sr-only">{localize('com_ui_field_required')}</span>
</>
)}
</Label>
<Input
id="oauth_client_id"
autoComplete="off"
placeholder={isEditMode ? localize('com_ui_leave_blank_to_keep') : ''}
aria-invalid={errors.auth?.oauth_client_id ? 'true' : 'false'}
aria-describedby={
errors.auth?.oauth_client_id ? 'oauth-client-id-error' : undefined
}
{...register('auth.oauth_client_id', { required: !isEditMode })}
className={cn(errors.auth?.oauth_client_id && 'border-red-500')}
className={cn(errors.auth?.oauth_client_id && 'border-border-destructive')}
/>
{errors.auth?.oauth_client_id && (
<p
id="oauth-client-id-error"
role="alert"
className="text-xs text-text-destructive"
>
{localize('com_ui_field_required')}
</p>
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="oauth_client_secret" className="text-sm font-medium">
{localize('com_ui_client_secret')}{' '}
{!isEditMode && <span className="text-text-secondary">*</span>}
{!isEditMode && (
<>
<span aria-hidden="true" className="text-text-secondary">
*
</span>
<span className="sr-only">{localize('com_ui_field_required')}</span>
</>
)}
</Label>
<SecretInput
id="oauth_client_secret"
placeholder={isEditMode ? localize('com_ui_leave_blank_to_keep') : ''}
aria-invalid={errors.auth?.oauth_client_secret ? 'true' : 'false'}
aria-describedby={
errors.auth?.oauth_client_secret ? 'oauth-client-secret-error' : undefined
}
{...register('auth.oauth_client_secret', { required: !isEditMode })}
className={cn(errors.auth?.oauth_client_secret && 'border-red-500')}
className={cn(errors.auth?.oauth_client_secret && 'border-border-destructive')}
/>
{errors.auth?.oauth_client_secret && (
<p
id="oauth-client-secret-error"
role="alert"
className="text-xs text-text-destructive"
>
{localize('com_ui_field_required')}
</p>
)}
</div>
</div>
@ -196,9 +250,12 @@ export default function AuthSection({ isEditMode, serverName }: AuthSectionProps
{/* Redirect URI */}
{isEditMode && redirectUri && (
<div className="space-y-1.5">
<Label className="text-sm font-medium">{localize('com_ui_redirect_uri')}</Label>
<Label htmlFor="auth-redirect-uri" className="text-sm font-medium">
{localize('com_ui_redirect_uri')}
</Label>
<div className="flex items-center gap-2">
<Input
id="auth-redirect-uri"
type="text"
readOnly
value={redirectUri}

View file

@ -1,9 +1,9 @@
import { useFormContext } from 'react-hook-form';
import { Input, Label, TextareaAutosize } from '@librechat/client';
import { Input, Label, Textarea } from '@librechat/client';
import type { MCPServerFormData } from '../hooks/useMCPServerForm';
import MCPIcon from '~/components/SidePanel/Agents/MCPIcon';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
import MCPIcon from '~/components/SidePanel/Agents/MCPIcon';
import type { MCPServerFormData } from '../hooks/useMCPServerForm';
export default function BasicInfoSection() {
const localize = useLocalize();
@ -36,13 +36,19 @@ export default function BasicInfoSection() {
<MCPIcon icon={iconValue} onIconChange={handleIconChange} />
</div>
<div className="w-full space-y-1.5 sm:flex-1">
<Label htmlFor="title" className="text-sm font-medium">
{localize('com_ui_name')} <span className="text-text-secondary">*</span>
<Label htmlFor="mcp-title" className="text-sm font-medium">
{localize('com_ui_name')}{' '}
<span aria-hidden="true" className="text-text-secondary">
*
</span>
<span className="sr-only">{localize('com_ui_field_required')}</span>
</Label>
<Input
id="title"
id="mcp-title"
autoComplete="off"
placeholder={localize('com_agents_mcp_name_placeholder')}
aria-invalid={errors.title ? 'true' : 'false'}
aria-describedby={errors.title ? 'mcp-title-error' : undefined}
{...register('title', {
required: localize('com_ui_field_required'),
pattern: {
@ -50,26 +56,26 @@ export default function BasicInfoSection() {
message: localize('com_ui_mcp_title_invalid'),
},
})}
className={cn(errors.title && 'border-red-500 focus:border-red-500')}
className={cn(errors.title && 'border-border-destructive')}
/>
{errors.title && <p className="text-xs text-red-500">{errors.title.message}</p>}
{errors.title && (
<p id="mcp-title-error" role="alert" className="text-xs text-text-destructive">
{errors.title.message}
</p>
)}
</div>
</div>
{/* Description - always visible, full width */}
{/* Description */}
<div className="space-y-1.5">
<Label htmlFor="description" className="text-sm font-medium">
<Label htmlFor="mcp-description" className="text-sm font-medium">
{localize('com_ui_description')}{' '}
<span className="text-xs text-text-secondary">{localize('com_ui_optional')}</span>
</Label>
<TextareaAutosize
id="description"
aria-label={localize('com_ui_description')}
<Textarea
id="mcp-description"
placeholder={localize('com_agents_mcp_description_placeholder')}
{...register('description')}
minRows={2}
maxRows={4}
className="w-full resize-none rounded-lg border border-input bg-transparent px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none"
/>
</div>
</div>

View file

@ -15,13 +15,19 @@ export default function ConnectionSection() {
return (
<div className="space-y-1.5">
<Label htmlFor="url" className="text-sm font-medium">
{localize('com_ui_mcp_url')} <span className="text-text-secondary">*</span>
{localize('com_ui_mcp_url')}{' '}
<span aria-hidden="true" className="text-text-secondary">
*
</span>
<span className="sr-only">{localize('com_ui_field_required')}</span>
</Label>
<Input
id="url"
type="url"
autoComplete="off"
placeholder={localize('com_ui_mcp_server_url_placeholder')}
aria-invalid={errors.url ? 'true' : 'false'}
aria-describedby={errors.url ? 'url-error' : undefined}
{...register('url', {
required: localize('com_ui_field_required'),
validate: (value) => {
@ -29,9 +35,13 @@ export default function ConnectionSection() {
return isValidUrl(normalized) || localize('com_ui_mcp_invalid_url');
},
})}
className={cn(errors.url && 'border-red-500 focus:border-red-500')}
className={cn(errors.url && 'border-border-destructive')}
/>
{errors.url && <p className="text-xs text-red-500">{errors.url.message}</p>}
{errors.url && (
<p id="url-error" role="alert" className="text-xs text-text-destructive">
{errors.url.message}
</p>
)}
</div>
);
}

View file

@ -25,14 +25,19 @@ export default function TransportSection() {
);
return (
<div className="space-y-2">
<Label className="text-sm font-medium">{localize('com_ui_mcp_transport')}</Label>
<fieldset className="space-y-2">
<legend>
<Label id="transport-label" className="text-sm font-medium">
{localize('com_ui_mcp_transport')}
</Label>
</legend>
<Radio
options={transportOptions}
value={transportType}
onChange={handleTransportChange}
fullWidth
aria-labelledby="transport-label"
/>
</div>
</fieldset>
);
}

View file

@ -26,17 +26,17 @@ export default function TrustSection() {
checked={field.value}
onCheckedChange={field.onChange}
aria-labelledby="trust-label"
aria-describedby="trust-description"
aria-describedby={
errors.trust ? 'trust-description trust-error' : 'trust-description'
}
aria-invalid={errors.trust ? 'true' : 'false'}
aria-required="true"
className="mt-0.5"
/>
)}
/>
<Label
id="trust-label"
htmlFor="trust"
className="flex cursor-pointer flex-col gap-0.5 text-sm"
>
<span className="font-medium text-text-primary">
<Label htmlFor="trust" className="flex cursor-pointer flex-col gap-0.5 text-sm">
<span id="trust-label" className="font-medium text-text-primary">
{startupConfig?.interface?.mcpServers?.trustCheckbox?.label ? (
<span
dangerouslySetInnerHTML={{
@ -49,7 +49,9 @@ export default function TrustSection() {
) : (
localize('com_ui_trust_app')
)}{' '}
<span className="text-text-secondary">*</span>
<span aria-hidden="true" className="text-text-secondary">
*
</span>
</span>
<span id="trust-description" className="text-xs font-normal text-text-secondary">
{startupConfig?.interface?.mcpServers?.trustCheckbox?.subLabel ? (
@ -68,7 +70,9 @@ export default function TrustSection() {
</Label>
</div>
{errors.trust && (
<p className="mt-2 text-xs text-red-500">{localize('com_ui_field_required')}</p>
<p id="trust-error" role="alert" className="mt-2 text-xs text-text-destructive">
{localize('com_ui_field_required')}
</p>
)}
</div>
);