diff --git a/l10n/bundle.l10n.json b/l10n/bundle.l10n.json index 2d330c9f3..5430a1a7a 100644 --- a/l10n/bundle.l10n.json +++ b/l10n/bundle.l10n.json @@ -614,6 +614,7 @@ "Port number is required": "Port number is required", "Port number must be a number": "Port number must be a number", "Port number must be between 1 and 65535": "Port number must be between 1 and 65535", + "Press Escape to exit editor": "Press Escape to exit editor", "Previous tip": "Previous tip", "Privacy Statement": "Privacy Statement", "Procedure not found: {name}": "Procedure not found: {name}", diff --git a/src/webviews/components/MonacoAutoHeight.tsx b/src/webviews/components/MonacoAutoHeight.tsx index d673843c7..9625d8f01 100644 --- a/src/webviews/components/MonacoAutoHeight.tsx +++ b/src/webviews/components/MonacoAutoHeight.tsx @@ -44,6 +44,11 @@ export type MonacoAutoHeightProps = EditorProps & { * When false (default), Tab navigation behaves like a standard input and moves focus to the next/previous focusable element. */ trapTabKey?: boolean; + /** + * Callback invoked when the user presses Escape key to exit the editor. + * If not provided, pressing Escape will move focus to the next focusable element. + */ + onEscapeEditor?: () => void; }; export const MonacoAutoHeight = (props: MonacoAutoHeightProps) => { @@ -80,7 +85,7 @@ export const MonacoAutoHeight = (props: MonacoAutoHeightProps) => { // These props are intentionally destructured but not used directly - they're handled specially // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { adaptiveHeight, onExecuteRequest, onMount, trapTabKey, ...editorProps } = props; + const { adaptiveHeight, onExecuteRequest, onMount, trapTabKey, onEscapeEditor, ...editorProps } = props; const handleMonacoEditorMount = ( editor: monacoEditor.editor.IStandaloneCodeEditor, @@ -262,9 +267,18 @@ export const MonacoAutoHeight = (props: MonacoAutoHeightProps) => { } }; + // Default escape handler: move focus to next element (like Tab) + const handleEscapeEditor = () => { + if (propsRef.current.onEscapeEditor) { + propsRef.current.onEscapeEditor(); + } else if (editorRef.current) { + moveFocus(editorRef.current, 'next'); + } + }; + return (
- +
); }; diff --git a/src/webviews/components/MonacoEditor.tsx b/src/webviews/components/MonacoEditor.tsx index 0a1dbd39d..c08e2087d 100644 --- a/src/webviews/components/MonacoEditor.tsx +++ b/src/webviews/components/MonacoEditor.tsx @@ -3,21 +3,65 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import Editor, { loader, useMonaco, type EditorProps } from '@monaco-editor/react'; +import Editor, { loader, useMonaco, type EditorProps, type OnMount } from '@monaco-editor/react'; // eslint-disable-next-line import/no-internal-modules import * as monacoEditor from 'monaco-editor/esm/vs/editor/editor.api'; import { useUncontrolledFocus } from '@fluentui/react-components'; -import { useEffect } from 'react'; +import * as l10n from '@vscode/l10n'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Announcer } from '../api/webview-client/accessibility'; import { useThemeState } from '../theme/state/ThemeContext'; loader.config({ monaco: monacoEditor }); -export const MonacoEditor = (props: EditorProps) => { +export interface MonacoEditorProps extends EditorProps { + /** + * Callback invoked when the user presses Escape key to exit the editor. + * Use this to move focus to a known element outside the editor. + */ + onEscapeEditor?: () => void; +} + +/** + * Monaco Editor wrapper with accessibility enhancements. + * + * ## Focus Trap Behavior + * + * Monaco Editor captures Tab/Shift-Tab for code indentation, creating a "tab trap" + * that can make keyboard navigation difficult. This component implements: + * + * 1. **Uncontrolled Focus Zone**: Uses Fluent UI's `useUncontrolledFocus` with + * `data-is-focus-trap-zone-bumper` attribute to tell Tabster that focus inside + * this zone is managed externally (by Monaco, not by Tabster's tab navigation). + * See: https://github.com/microsoft/fluentui/blob/0f490a4fea60df6b2ad0f5a6e088017df7ce1d54/packages/react-components/react-tabster/src/hooks/useTabster.ts#L34 + * + * 2. **Escape Key Exit**: When `onEscapeEditor` is provided, pressing Escape + * allows keyboard users to exit the editor and move focus elsewhere. + * + * 3. **Screen Reader Announcement**: Announces "Press Escape to exit editor" + * once when the editor receives focus (only announced once per focus session). + */ +export const MonacoEditor = ({ onEscapeEditor, onMount, ...props }: MonacoEditorProps) => { const monaco = useMonaco(); const themeState = useThemeState(); const uncontrolledFocus = useUncontrolledFocus(); + // Track whether we should announce the escape hint (once per focus session) + const [shouldAnnounce, setShouldAnnounce] = useState(false); + const hasAnnouncedRef = useRef(false); + + // Store disposables for cleanup + const disposablesRef = useRef([]); + + // Cleanup disposables on unmount + useEffect(() => { + return () => { + disposablesRef.current.forEach((d) => d.dispose()); + disposablesRef.current = []; + }; + }, []); + useEffect(() => { if (monaco && themeState.monaco.theme) { monaco.editor.defineTheme(themeState.monaco.themeName, themeState.monaco.theme); @@ -25,11 +69,49 @@ export const MonacoEditor = (props: EditorProps) => { } }, [monaco, themeState]); + const handleMount: OnMount = useCallback( + (editor, monacoInstance) => { + // Dispose any previous listeners (in case of re-mount) + disposablesRef.current.forEach((d) => d.dispose()); + disposablesRef.current = []; + + // Register Escape key handler to exit the editor + if (onEscapeEditor) { + editor.addCommand(monacoInstance.KeyCode.Escape, () => { + onEscapeEditor(); + }); + } + + // Announce escape hint once when editor gains focus + const focusDisposable = editor.onDidFocusEditorText(() => { + if (!hasAnnouncedRef.current && onEscapeEditor) { + setShouldAnnounce(true); + hasAnnouncedRef.current = true; + } + }); + disposablesRef.current.push(focusDisposable); + + // Reset announcement tracking when editor loses focus + const blurDisposable = editor.onDidBlurEditorText(() => { + setShouldAnnounce(false); + hasAnnouncedRef.current = false; + }); + disposablesRef.current.push(blurDisposable); + + // Call the original onMount if provided + onMount?.(editor, monacoInstance); + }, + [onEscapeEditor, onMount], + ); + return (
{ left: '0px', }} > - + {/* Screen reader announcement for escape key hint */} + +
); }; diff --git a/src/webviews/documentdb/collectionView/components/resultsTab/DataViewPanelJSON.tsx b/src/webviews/documentdb/collectionView/components/resultsTab/DataViewPanelJSON.tsx index 8d3bdbc3d..42a5ef057 100644 --- a/src/webviews/documentdb/collectionView/components/resultsTab/DataViewPanelJSON.tsx +++ b/src/webviews/documentdb/collectionView/components/resultsTab/DataViewPanelJSON.tsx @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { useFocusFinders } from '@fluentui/react-components'; import { debounce } from 'es-toolkit'; import * as React from 'react'; import { MonacoEditor } from '../../../../components/MonacoEditor'; @@ -25,6 +26,7 @@ const monacoOptions = { export const DataViewPanelJSON = ({ value }: Props): React.JSX.Element => { const editorRef = React.useRef(null); + const { findNextFocusable } = useFocusFinders(); React.useEffect(() => { // Add ResizeObserver to watch parent container size changes @@ -59,6 +61,24 @@ export const DataViewPanelJSON = ({ value }: Props): React.JSX.Element => { } }; + // Handle Escape key: move focus to next focusable element + const handleEscapeEditor = React.useCallback(() => { + const editorDomNode = editorRef.current?.getDomNode(); + if (!editorDomNode) { + return; + } + + const activeElement = document.activeElement as HTMLElement | null; + const startElement = activeElement ?? (editorDomNode as HTMLElement); + const nextElement = findNextFocusable(startElement); + + if (nextElement) { + nextElement.focus(); + } else { + activeElement?.blur(); + } + }, [findNextFocusable]); + return ( { editorRef.current = editor; handleResize(); }} + onEscapeEditor={handleEscapeEditor} value={value.join('\n\n')} /> ); diff --git a/src/webviews/documentdb/documentView/components/toolbarDocuments.tsx b/src/webviews/documentdb/documentView/components/toolbarDocuments.tsx index e3768548d..635d81d45 100644 --- a/src/webviews/documentdb/documentView/components/toolbarDocuments.tsx +++ b/src/webviews/documentdb/documentView/components/toolbarDocuments.tsx @@ -6,7 +6,7 @@ import { Toolbar, ToolbarButton, Tooltip } from '@fluentui/react-components'; import { ArrowClockwiseRegular, SaveRegular, TextGrammarCheckmarkRegular } from '@fluentui/react-icons'; import * as l10n from '@vscode/l10n'; -import { type JSX } from 'react'; +import { type JSX, type RefObject } from 'react'; import { ToolbarDividerTransparent } from '../../collectionView/components/toolbar/ToolbarDividerTransparent'; interface ToolbarDocumentsProps { @@ -14,6 +14,9 @@ interface ToolbarDocumentsProps { onValidateRequest: () => void; onRefreshRequest: () => void; onSaveRequest: () => void; + // Must accept null because useRef(null) creates RefObject + // See: https://react.dev/reference/react/useRef#typing-the-ref-with-an-initial-null-value + saveButtonRef?: RefObject; } export const ToolbarDocuments = ({ @@ -21,11 +24,13 @@ export const ToolbarDocuments = ({ onValidateRequest, onRefreshRequest, onSaveRequest, + saveButtonRef, }: ToolbarDocumentsProps): JSX.Element => { return ( } diff --git a/src/webviews/documentdb/documentView/documentView.tsx b/src/webviews/documentdb/documentView/documentView.tsx index 18e0a5129..287a6e176 100644 --- a/src/webviews/documentdb/documentView/documentView.tsx +++ b/src/webviews/documentdb/documentView/documentView.tsx @@ -7,7 +7,7 @@ import { ProgressBar } from '@fluentui/react-components'; import { loader } from '@monaco-editor/react'; import * as l10n from '@vscode/l10n'; import { debounce } from 'es-toolkit'; -import { type JSX, useEffect, useRef, useState } from 'react'; +import { type JSX, useCallback, useEffect, useRef, useState } from 'react'; // eslint-disable-next-line import/no-internal-modules import * as monacoEditor from 'monaco-editor/esm/vs/editor/editor.api'; import { UsageImpact } from '../../../utils/surveyTypes'; @@ -57,6 +57,9 @@ export const DocumentView = (): JSX.Element => { const [isLoading, setIsLoading] = useState(configuration.mode !== 'add'); const [isDirty, setIsDirty] = useState(true); + // Ref for the Save button to manage focus + const saveButtonRef = useRef(null); + useSelectiveContextMenuPrevention(); // a useEffect without a dependency runs only once after the first render only @@ -97,6 +100,13 @@ export const DocumentView = (): JSX.Element => { handleResize(); + // Accessibility: Focus the Save button instead of the editor + // Monaco editor captures Tab/Shift-Tab for document editing, making it difficult + // for keyboard users to navigate away. Setting focus on the toolbar button + // provides better keyboard navigation until Tab navigation from editor is improved. + // Addresses WCAG 2.4.3 Focus Order requirement. + saveButtonRef.current?.focus(); + // initialize the monaco editor with the schema that's basic // as we don't know the schema of the collection available // this is a fallback for the case when the autocompletion feature fails. @@ -264,6 +274,11 @@ export const DocumentView = (): JSX.Element => { function handleOnValidateRequest(): void {} + // Accessibility: Handle Escape key to exit Monaco editor + const handleEscapeEditor = useCallback(() => { + saveButtonRef.current?.focus(); + }, []); + return (
@@ -273,6 +288,7 @@ export const DocumentView = (): JSX.Element => { onSaveRequest={handleOnSaveRequest} onValidateRequest={handleOnValidateRequest} onRefreshRequest={handleOnRefreshRequest} + saveButtonRef={saveButtonRef} />
@@ -283,6 +299,7 @@ export const DocumentView = (): JSX.Element => { options={monacoOptions} value={editorContent} onMount={handleMonacoEditorMount} + onEscapeEditor={handleEscapeEditor} onChange={() => { setIsDirty(true); }}