fix(a11y): improve screen reader (NVDA) support with aria-label attributes (#11678)

* fix(a11y): improve screen reader support with aria-label attributes

Add aria-label attributes to all interactive buttons and toolbar elements
to improve accessibility for screen reader users (NVDA, etc.).

Changes:
- Add aria-label with i18n translations to all ActionIconButton components
- Add role="button", tabIndex, and keyboard handlers for non-semantic elements
- Fix hardcoded English aria-labels in WindowControls to use i18n
- Add aria-pressed for toggle buttons to indicate state
- Add aria-expanded for expandable menus
- Add aria-disabled for disabled buttons

Components updated:
- SendMessageButton, CopyButton, SelectionToolbar
- CodeToolbar, RichEditor toolbar, MinimalToolbar
- WindowControls
- 12 Inputbar tool buttons (WebSearch, Attachment, KnowledgeBase, etc.)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(a11y): enhance accessibility in CodeToolbar snapshot

Added aria-label, role, and tabindex attributes to improve screen reader support for interactive elements in the CodeToolbar component. This change aligns with ongoing efforts to enhance accessibility across the application.

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
fullex 2025-12-04 14:46:37 +08:00 committed by GitHub
parent fb20173194
commit 9637fb8a43
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 183 additions and 59 deletions

View File

@ -64,7 +64,11 @@ exports[`CodeToolbar > basic rendering > should match snapshot with mixed tools
data-title="code_block.more"
>
<div
aria-expanded="false"
aria-label="code_block.more"
class="c2"
role="button"
tabindex="0"
>
<div
class="tool-icon"

View File

@ -1,6 +1,6 @@
import type { ActionTool } from '@renderer/components/ActionTools'
import { Dropdown, Tooltip } from 'antd'
import { memo, useMemo } from 'react'
import { memo, useCallback, useMemo } from 'react'
import { ToolWrapper } from './styles'
@ -9,13 +9,30 @@ interface CodeToolButtonProps {
}
const CodeToolButton = ({ tool }: CodeToolButtonProps) => {
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
tool.onClick?.()
}
},
[tool]
)
const mainTool = useMemo(
() => (
<Tooltip key={tool.id} title={tool.tooltip} mouseEnterDelay={0.5} mouseLeaveDelay={0}>
<ToolWrapper onClick={tool.onClick}>{tool.icon}</ToolWrapper>
<ToolWrapper
onClick={tool.onClick}
onKeyDown={handleKeyDown}
role="button"
aria-label={tool.tooltip}
tabIndex={0}>
{tool.icon}
</ToolWrapper>
</Tooltip>
),
[tool]
[tool, handleKeyDown]
)
if (tool.children?.length && tool.children.length > 0) {

View File

@ -40,7 +40,19 @@ const CodeToolbar = ({ tools }: { tools: ActionTool[] }) => {
{quickToolButtons}
{quickTools.length > 1 && (
<Tooltip title={t('code_block.more')} mouseEnterDelay={0.5}>
<ToolWrapper onClick={() => setShowQuickTools(!showQuickTools)} className={showQuickTools ? 'active' : ''}>
<ToolWrapper
onClick={() => setShowQuickTools(!showQuickTools)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setShowQuickTools(!showQuickTools)
}
}}
className={showQuickTools ? 'active' : ''}
role="button"
aria-label={t('code_block.more')}
aria-expanded={showQuickTools}
tabIndex={0}>
<EllipsisVertical className="tool-icon" />
</ToolWrapper>
</Tooltip>

View File

@ -1,6 +1,6 @@
import { Tooltip } from 'antd'
import { Copy } from 'lucide-react'
import type { FC } from 'react'
import type { FC, KeyboardEvent } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@ -39,8 +39,24 @@ const CopyButton: FC<CopyButtonProps> = ({
})
}
const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
handleCopy()
}
}
const ariaLabel = tooltip || t('common.copy')
const button = (
<ButtonContainer $color={color} $hoverColor={hoverColor} onClick={handleCopy}>
<ButtonContainer
$color={color}
$hoverColor={hoverColor}
onClick={handleCopy}
onKeyDown={handleKeyDown}
role="button"
aria-label={ariaLabel}
tabIndex={0}>
<Copy size={size} className="copy-icon" />
{label && <RightText size={size}>{label}</RightText>}
</ButtonContainer>

View File

@ -171,7 +171,9 @@ export const Toolbar: React.FC<ToolbarProps> = ({ editor, formattingState, onCom
data-active={isActive}
disabled={isDisabled}
onClick={() => handleCommand(command)}
data-testid={`toolbar-${command}`}>
data-testid={`toolbar-${command}`}
aria-label={tooltipText}
aria-pressed={isActive}>
<Icon color={isActive ? 'var(--color-primary)' : 'var(--color-text)'} />
</ToolbarButton>
)

View File

@ -86,7 +86,7 @@ const WindowControls: React.FC = () => {
return (
<WindowControlsContainer>
<Tooltip title={t('navbar.window.minimize')} placement="bottom" mouseEnterDelay={DEFAULT_DELAY}>
<ControlButton onClick={handleMinimize} aria-label="Minimize">
<ControlButton onClick={handleMinimize} aria-label={t('navbar.window.minimize')}>
<Minus size={14} />
</ControlButton>
</Tooltip>
@ -94,12 +94,14 @@ const WindowControls: React.FC = () => {
title={isMaximized ? t('navbar.window.restore') : t('navbar.window.maximize')}
placement="bottom"
mouseEnterDelay={DEFAULT_DELAY}>
<ControlButton onClick={handleMaximize} aria-label={isMaximized ? 'Restore' : 'Maximize'}>
<ControlButton
onClick={handleMaximize}
aria-label={isMaximized ? t('navbar.window.restore') : t('navbar.window.maximize')}>
{isMaximized ? <WindowRestoreIcon size={14} /> : <Square size={14} />}
</ControlButton>
</Tooltip>
<Tooltip title={t('navbar.window.close')} placement="bottom" mouseEnterDelay={DEFAULT_DELAY}>
<ControlButton $isClose onClick={handleClose} aria-label="Close">
<ControlButton $isClose onClick={handleClose} aria-label={t('navbar.window.close')}>
<X size={17} />
</ControlButton>
</Tooltip>

View File

@ -1,4 +1,5 @@
import type { FC } from 'react'
import type { FC, KeyboardEvent } from 'react'
import { useTranslation } from 'react-i18next'
interface Props {
disabled: boolean
@ -6,10 +7,24 @@ interface Props {
}
const SendMessageButton: FC<Props> = ({ disabled, sendMessage }) => {
const { t } = useTranslation()
const handleKeyDown = (e: KeyboardEvent<HTMLElement>) => {
if (!disabled && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault()
sendMessage()
}
}
return (
<i
className="iconfont icon-ic_send"
onClick={sendMessage}
onClick={disabled ? undefined : sendMessage}
onKeyDown={handleKeyDown}
role="button"
aria-label={t('chat.input.send')}
aria-disabled={disabled}
tabIndex={disabled ? -1 : 0}
style={{
cursor: disabled ? 'not-allowed' : 'pointer',
color: disabled ? 'var(--color-text-3)' : 'var(--color-primary)',

View File

@ -31,7 +31,7 @@ const ActivityDirectoryButton: FC<Props> = ({ quickPanel, quickPanelController,
return (
<Tooltip placement="top" title={t('chat.input.activity_directory.title')} mouseLeaveDelay={0} arrow>
<ActionIconButton onClick={handleOpenQuickPanel}>
<ActionIconButton onClick={handleOpenQuickPanel} aria-label={t('chat.input.activity_directory.title')}>
<FolderOpen size={18} />
</ActionIconButton>
</Tooltip>

View File

@ -152,13 +152,15 @@ const AttachmentButton: FC<Props> = ({ quickPanel, couldAddImageFile, extensions
}
}, [couldAddImageFile, openQuickPanel, quickPanel, t])
const ariaLabel = couldAddImageFile ? t('chat.input.upload.image_or_document') : t('chat.input.upload.document')
return (
<Tooltip
placement="top"
title={couldAddImageFile ? t('chat.input.upload.image_or_document') : t('chat.input.upload.document')}
mouseLeaveDelay={0}
arrow>
<ActionIconButton onClick={openFileSelectDialog} active={files.length > 0} disabled={disabled}>
<Tooltip placement="top" title={ariaLabel} mouseLeaveDelay={0} arrow>
<ActionIconButton
onClick={openFileSelectDialog}
active={files.length > 0}
disabled={disabled}
aria-label={ariaLabel}>
<Paperclip size={18} />
</ActionIconButton>
</Tooltip>

View File

@ -15,18 +15,18 @@ interface Props {
const GenerateImageButton: FC<Props> = ({ model, assistant, onEnableGenerateImage }) => {
const { t } = useTranslation()
const ariaLabel = isGenerateImageModel(model)
? t('chat.input.generate_image')
: t('chat.input.generate_image_not_supported')
return (
<Tooltip
placement="top"
title={
isGenerateImageModel(model) ? t('chat.input.generate_image') : t('chat.input.generate_image_not_supported')
}
mouseLeaveDelay={0}
arrow>
<Tooltip placement="top" title={ariaLabel} mouseLeaveDelay={0} arrow>
<ActionIconButton
onClick={onEnableGenerateImage}
active={assistant.enableGenerateImage}
disabled={!isGenerateImageModel(model)}>
disabled={!isGenerateImageModel(model)}
aria-label={ariaLabel}
aria-pressed={assistant.enableGenerateImage}>
<Image size={18} />
</ActionIconButton>
</Tooltip>

View File

@ -124,7 +124,8 @@ const KnowledgeBaseButton: FC<Props> = ({ quickPanel, selectedBases, onSelect, d
<ActionIconButton
onClick={handleOpenQuickPanel}
active={selectedBases && selectedBases.length > 0}
disabled={disabled}>
disabled={disabled}
aria-label={t('chat.input.knowledge_base')}>
<FileSearch size={18} />
</ActionIconButton>
</Tooltip>

View File

@ -516,7 +516,10 @@ const MCPToolsButton: FC<Props> = ({ quickPanel, setInputValue, resizeTextArea,
return (
<Tooltip placement="top" title={t('settings.mcp.title')} mouseLeaveDelay={0} arrow>
<ActionIconButton onClick={handleOpenQuickPanel} active={assistant.mcpServers && assistant.mcpServers.length > 0}>
<ActionIconButton
onClick={handleOpenQuickPanel}
active={assistant.mcpServers && assistant.mcpServers.length > 0}
aria-label={t('settings.mcp.title')}>
<Hammer size={18} />
</ActionIconButton>
</Tooltip>

View File

@ -46,7 +46,10 @@ const MentionModelsButton: FC<Props> = ({
return (
<Tooltip placement="top" title={t('assistants.presets.edit.model.select.title')} mouseLeaveDelay={0} arrow>
<ActionIconButton onClick={handleOpenQuickPanel} active={mentionedModels.length > 0}>
<ActionIconButton
onClick={handleOpenQuickPanel}
active={mentionedModels.length > 0}
aria-label={t('assistants.presets.edit.model.select.title')}>
<AtSign size={18} />
</ActionIconButton>
</Tooltip>

View File

@ -20,7 +20,9 @@ const NewContextButton: FC<Props> = ({ onNewContext }) => {
title={t('chat.input.new.context', { Command: newContextShortcut })}
mouseLeaveDelay={0}
arrow>
<ActionIconButton onClick={onNewContext}>
<ActionIconButton
onClick={onNewContext}
aria-label={t('chat.input.new.context', { Command: newContextShortcut })}>
<Eraser size={18} />
</ActionIconButton>
</Tooltip>

View File

@ -250,7 +250,7 @@ const QuickPhrasesButton = ({ quickPanel, setInputValue, resizeTextArea, assista
return (
<>
<Tooltip placement="top" title={t('settings.quickPhrase.title')} mouseLeaveDelay={0} arrow>
<ActionIconButton onClick={handleOpenQuickPanel}>
<ActionIconButton onClick={handleOpenQuickPanel} aria-label={t('settings.quickPhrase.title')}>
<Zap size={18} />
</ActionIconButton>
</Tooltip>

View File

@ -37,7 +37,11 @@ const SlashCommandsButton: FC<Props> = ({ quickPanelController, session, openPan
return (
<Tooltip placement="top" title={t('chat.input.slash_commands.title')} mouseLeaveDelay={0} arrow>
<ActionIconButton onClick={handleOpenQuickPanel} active={isActive} disabled={!hasCommands}>
<ActionIconButton
onClick={handleOpenQuickPanel}
active={isActive}
disabled={!hasCommands}
aria-label={t('chat.input.slash_commands.title')}>
<Terminal size={18} />
</ActionIconButton>
</Tooltip>

View File

@ -142,17 +142,18 @@ const ThinkingButton: FC<Props> = ({ quickPanel, model, assistantId }): ReactEle
}
}, [currentReasoningEffort, openQuickPanel, quickPanel, t])
const ariaLabel =
isThinkingEnabled && supportedOptions.includes('none')
? t('common.close')
: t('assistants.settings.reasoning_effort.label')
return (
<Tooltip
placement="top"
title={
isThinkingEnabled && supportedOptions.includes('none')
? t('common.close')
: t('assistants.settings.reasoning_effort.label')
}
mouseLeaveDelay={0}
arrow>
<ActionIconButton onClick={handleOpenQuickPanel} active={currentReasoningEffort !== 'none'}>
<Tooltip placement="top" title={ariaLabel} mouseLeaveDelay={0} arrow>
<ActionIconButton
onClick={handleOpenQuickPanel}
active={currentReasoningEffort !== 'none'}
aria-label={ariaLabel}
aria-pressed={currentReasoningEffort !== 'none'}>
{ThinkingIcon(currentReasoningEffort)}
</ActionIconButton>
</Tooltip>

View File

@ -48,7 +48,11 @@ const UrlContextButton: FC<Props> = ({ assistantId }) => {
return (
<Tooltip placement="top" title={t('chat.input.url_context')} arrow>
<ActionIconButton onClick={handleToggle} active={assistant.enableUrlContext}>
<ActionIconButton
onClick={handleToggle}
active={assistant.enableUrlContext}
aria-label={t('chat.input.url_context')}
aria-pressed={assistant.enableUrlContext}>
<Link size={18} />
</ActionIconButton>
</Tooltip>

View File

@ -25,13 +25,15 @@ const WebSearchButton: FC<Props> = ({ quickPanelController, assistantId }) => {
}
}, [enableWebSearch, toggleQuickPanel, updateWebSearchProvider])
const ariaLabel = enableWebSearch ? t('common.close') : t('chat.input.web_search.label')
return (
<Tooltip
placement="top"
title={enableWebSearch ? t('common.close') : t('chat.input.web_search.label')}
mouseLeaveDelay={0}
arrow>
<ActionIconButton onClick={onClick} active={!!enableWebSearch}>
<Tooltip placement="top" title={ariaLabel} mouseLeaveDelay={0} arrow>
<ActionIconButton
onClick={onClick}
active={!!enableWebSearch}
aria-label={ariaLabel}
aria-pressed={!!enableWebSearch}>
<WebSearchProviderIcon pid={selectedProviderId} />
</ActionIconButton>
</Tooltip>

View File

@ -238,19 +238,27 @@ const MinimalToolbar: FC<Props> = ({ app, webviewRef, currentUrl, onReload, onOp
<LeftSection>
<ButtonGroup>
<Tooltip title={t('minapp.popup.goBack')} placement="bottom">
<ToolbarButton onClick={handleGoBack} $disabled={!canGoBack}>
<ToolbarButton
onClick={handleGoBack}
$disabled={!canGoBack}
aria-label={t('minapp.popup.goBack')}
aria-disabled={!canGoBack}>
<ArrowLeftOutlined />
</ToolbarButton>
</Tooltip>
<Tooltip title={t('minapp.popup.goForward')} placement="bottom">
<ToolbarButton onClick={handleGoForward} $disabled={!canGoForward}>
<ToolbarButton
onClick={handleGoForward}
$disabled={!canGoForward}
aria-label={t('minapp.popup.goForward')}
aria-disabled={!canGoForward}>
<ArrowRightOutlined />
</ToolbarButton>
</Tooltip>
<Tooltip title={t('minapp.popup.refresh')} placement="bottom">
<ToolbarButton onClick={onReload}>
<ToolbarButton onClick={onReload} aria-label={t('minapp.popup.refresh')}>
<ReloadOutlined />
</ToolbarButton>
</Tooltip>
@ -261,7 +269,7 @@ const MinimalToolbar: FC<Props> = ({ app, webviewRef, currentUrl, onReload, onOp
<ButtonGroup>
{canOpenExternalLink && (
<Tooltip title={t('minapp.popup.openExternal')} placement="bottom">
<ToolbarButton onClick={handleOpenLink}>
<ToolbarButton onClick={handleOpenLink} aria-label={t('minapp.popup.openExternal')}>
<ExportOutlined />
</ToolbarButton>
</Tooltip>
@ -271,7 +279,11 @@ const MinimalToolbar: FC<Props> = ({ app, webviewRef, currentUrl, onReload, onOp
<Tooltip
title={isPinned ? t('minapp.remove_from_launchpad') : t('minapp.add_to_launchpad')}
placement="bottom">
<ToolbarButton onClick={handleTogglePin} $active={isPinned}>
<ToolbarButton
onClick={handleTogglePin}
$active={isPinned}
aria-label={isPinned ? t('minapp.remove_from_launchpad') : t('minapp.add_to_launchpad')}
aria-pressed={isPinned}>
<PushpinOutlined />
</ToolbarButton>
</Tooltip>
@ -284,21 +296,29 @@ const MinimalToolbar: FC<Props> = ({ app, webviewRef, currentUrl, onReload, onOp
: t('minapp.popup.open_link_external_off')
}
placement="bottom">
<ToolbarButton onClick={handleToggleOpenExternal} $active={minappsOpenLinkExternal}>
<ToolbarButton
onClick={handleToggleOpenExternal}
$active={minappsOpenLinkExternal}
aria-label={
minappsOpenLinkExternal
? t('minapp.popup.open_link_external_on')
: t('minapp.popup.open_link_external_off')
}
aria-pressed={minappsOpenLinkExternal}>
<LinkOutlined />
</ToolbarButton>
</Tooltip>
{isDev && (
<Tooltip title={t('minapp.popup.devtools')} placement="bottom">
<ToolbarButton onClick={onOpenDevTools}>
<ToolbarButton onClick={onOpenDevTools} aria-label={t('minapp.popup.devtools')}>
<CodeOutlined />
</ToolbarButton>
</Tooltip>
)}
<Tooltip title={t('minapp.popup.minimize')} placement="bottom">
<ToolbarButton onClick={handleMinimize}>
<ToolbarButton onClick={handleMinimize} aria-label={t('minapp.popup.minimize')}>
<MinusOutlined />
</ToolbarButton>
</Tooltip>

View File

@ -72,8 +72,22 @@ const ActionIcons: FC<{
(action: ActionItem) => {
const displayName = action.isBuiltIn ? t(action.name) : action.name
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
handleAction(action)
}
}
return (
<ActionButton key={action.id} onClick={() => handleAction(action)} title={isCompact ? displayName : undefined}>
<ActionButton
key={action.id}
onClick={() => handleAction(action)}
onKeyDown={handleKeyDown}
title={isCompact ? displayName : undefined}
role="button"
aria-label={displayName}
tabIndex={0}>
<ActionIcon>
{action.id === 'copy' ? (
renderCopyIcon()