mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-07 08:26:51 +00:00
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:
parent
8c349767fa
commit
7f3fe01a33
2 changed files with 83 additions and 0 deletions
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue