fix: improve accessibility of @ context menu for screen readers (#3186)

- Add proper ARIA roles and properties to ContextMenu component
- Add role="listbox" and aria-label to menu container
- Add role="option" and aria-selected to menu items
- Add aria-expanded, aria-haspopup, and aria-controls to textarea
- Add live region for screen reader announcements
- Announce menu state changes (open/close)
- Announce selected menu items with position info
- Add instructions for screen reader users
- Improve keyboard navigation accessibility

Fixes #3186
This commit is contained in:
Roo Code 2025-07-18 18:54:55 +00:00
parent 8c349767fa
commit 7f3fe01a33
2 changed files with 83 additions and 0 deletions

View file

@ -180,6 +180,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
const contextMenuContainerRef = useRef<HTMLDivElement>(null)
const [isEnhancingPrompt, setIsEnhancingPrompt] = useState(false)
const [isFocused, setIsFocused] = useState(false)
const [screenReaderAnnouncement, setScreenReaderAnnouncement] = useState("")
// Use custom hook for prompt history navigation
const { handleHistoryNavigation, resetHistoryNavigation, resetOnInputChange } = usePromptHistory({
@ -500,8 +501,16 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
setCursorPosition(newCursorPosition)
const showMenu = shouldShowContextMenu(newValue, newCursorPosition)
const wasMenuVisible = showContextMenu
setShowContextMenu(showMenu)
// Announce menu state changes for screen readers
if (showMenu && !wasMenuVisible) {
setScreenReaderAnnouncement("File insertion menu opened")
} else if (!showMenu && wasMenuVisible) {
setScreenReaderAnnouncement("File insertion menu closed")
}
if (showMenu) {
if (newValue.startsWith("/")) {
// Handle slash command.
@ -559,6 +568,48 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
}
}, [showContextMenu])
// Announce selected menu item for screen readers
useEffect(() => {
if (showContextMenu && selectedMenuIndex >= 0) {
const options = getContextMenuOptions(
searchQuery,
inputValue,
selectedType,
queryItems,
fileSearchResults,
allModes,
)
const selectedOption = options[selectedMenuIndex]
if (selectedOption && selectedOption.type !== ContextMenuOptionType.NoResults) {
let announcement = ""
switch (selectedOption.type) {
case ContextMenuOptionType.File:
case ContextMenuOptionType.OpenedFile:
announcement = `File: ${selectedOption.value || selectedOption.label}, ${selectedMenuIndex + 1} of ${options.length}`
break
case ContextMenuOptionType.Folder:
announcement = `Folder: ${selectedOption.value || selectedOption.label}, ${selectedMenuIndex + 1} of ${options.length}`
break
case ContextMenuOptionType.Problems:
announcement = `Problems, ${selectedMenuIndex + 1} of ${options.length}`
break
case ContextMenuOptionType.Terminal:
announcement = `Terminal, ${selectedMenuIndex + 1} of ${options.length}`
break
case ContextMenuOptionType.Git:
announcement = `Git: ${selectedOption.label || selectedOption.value}, ${selectedMenuIndex + 1} of ${options.length}`
break
case ContextMenuOptionType.Mode:
announcement = `Mode: ${selectedOption.label}, ${selectedMenuIndex + 1} of ${options.length}`
break
default:
announcement = `${selectedOption.label || selectedOption.value}, ${selectedMenuIndex + 1} of ${options.length}`
}
setScreenReaderAnnouncement(announcement)
}
}
}, [showContextMenu, selectedMenuIndex, searchQuery, inputValue, selectedType, queryItems, fileSearchResults, allModes])
const handleBlur = useCallback(() => {
// Only hide the context menu if the user didn't click on it.
if (!isMouseDownOnMenu) {
@ -1076,6 +1127,10 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
minRows={3}
maxRows={15}
autoFocus={true}
aria-expanded={showContextMenu}
aria-haspopup="listbox"
aria-controls={showContextMenu ? "context-menu" : undefined}
aria-describedby="context-menu-instructions"
className={cn(
"w-full",
"text-vscode-input-foreground",
@ -1249,6 +1304,26 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
</div>
)}
{/* Live region for screen reader announcements */}
<div
aria-live="polite"
aria-atomic="true"
className="sr-only"
style={{
position: "absolute",
left: "-10000px",
width: "1px",
height: "1px",
overflow: "hidden",
}}>
{screenReaderAnnouncement}
</div>
{/* Instructions for screen readers */}
<div id="context-menu-instructions" className="sr-only">
Type @ to open file insertion menu. Use arrow keys to navigate, Enter to select, Escape to close.
</div>
{renderTextAreaSection()}
</div>

View file

@ -208,7 +208,11 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
}}
onMouseDown={onMouseDown}>
<div
id="context-menu"
ref={menuRef}
role="listbox"
aria-label="File insertion menu"
aria-activedescendant={selectedIndex >= 0 ? `context-menu-option-${selectedIndex}` : undefined}
style={{
backgroundColor: "var(--vscode-dropdown-background)",
border: "1px solid var(--vscode-editorGroup-border)",
@ -224,6 +228,10 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
filteredOptions.map((option, index) => (
<div
key={`${option.type}-${option.value || index}`}
id={`context-menu-option-${index}`}
role="option"
aria-selected={index === selectedIndex && isOptionSelectable(option)}
aria-disabled={!isOptionSelectable(option)}
onClick={() => isOptionSelectable(option) && onSelect(option.type, option.value)}
style={{
padding: "4px 6px",