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);
}}