Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions l10n/bundle.l10n.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}",
Expand Down
18 changes: 16 additions & 2 deletions src/webviews/components/MonacoAutoHeight.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 (
<div className="monacoAutoHeightContainer" style={{ height: editorHeight }}>
<MonacoEditor {...editorProps} onMount={handleMonacoEditorMount} />
<MonacoEditor {...editorProps} onMount={handleMonacoEditorMount} onEscapeEditor={handleEscapeEditor} />
</div>
);
};
101 changes: 95 additions & 6 deletions src/webviews/components/MonacoEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,33 +3,115 @@
* 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<monacoEditor.IDisposable[]>([]);

// 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);
monaco.editor.setTheme(themeState.monaco.themeName);
}
}, [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 (
<section {...uncontrolledFocus} style={{ width: '100%', height: '100%' }}>
<i
// The hack to make the focus trap work
// https://github.com/microsoft/fluentui/blob/0f490a4fea60df6b2ad0f5a6e088017df7ce1d54/packages/react-components/react-tabster/src/hooks/useTabster.ts#L34
// Focus trap bumper element for Fluent UI Tabster integration.
// Tabster's checkUncontrolledTrappingFocus checks for this attribute
// to identify zones where tab navigation is managed externally.
// IMPORTANT: This MUST be the first child element - the check uses
// element.firstElementChild?.hasAttribute('data-is-focus-trap-zone-bumper')
data-is-focus-trap-zone-bumper={true}
style={{
position: 'fixed',
Expand All @@ -42,7 +124,14 @@ export const MonacoEditor = (props: EditorProps) => {
left: '0px',
}}
></i>
<Editor {...props} data-is-focus-trap-zone-bumper={'true'} theme={themeState.monaco.themeName} />
{/* Screen reader announcement for escape key hint */}
<Announcer when={shouldAnnounce} message={l10n.t('Press Escape to exit editor')} />
<Editor
{...props}
data-is-focus-trap-zone-bumper={'true'}
onMount={handleMount}
theme={themeState.monaco.themeName}
/>
</section>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -25,6 +26,7 @@ const monacoOptions = {

export const DataViewPanelJSON = ({ value }: Props): React.JSX.Element => {
const editorRef = React.useRef<monacoEditor.editor.IStandaloneCodeEditor | null>(null);
const { findNextFocusable } = useFocusFinders();

React.useEffect(() => {
// Add ResizeObserver to watch parent container size changes
Expand Down Expand Up @@ -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 (
<MonacoEditor
height={'100%'}
Expand All @@ -70,6 +90,7 @@ export const DataViewPanelJSON = ({ value }: Props): React.JSX.Element => {
editorRef.current = editor;
handleResize();
}}
onEscapeEditor={handleEscapeEditor}
value={value.join('\n\n')}
/>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,26 +6,31 @@
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 {
disableSaveButton: boolean;
onValidateRequest: () => void;
onRefreshRequest: () => void;
onSaveRequest: () => void;
// Must accept null because useRef<HTMLButtonElement>(null) creates RefObject<HTMLButtonElement | null>
// See: https://react.dev/reference/react/useRef#typing-the-ref-with-an-initial-null-value
saveButtonRef?: RefObject<HTMLButtonElement | null>;
}

export const ToolbarDocuments = ({
disableSaveButton,
onValidateRequest,
onRefreshRequest,
onSaveRequest,
saveButtonRef,
}: ToolbarDocumentsProps): JSX.Element => {
return (
<Toolbar size="small">
<Tooltip content={l10n.t('Save document to the database')} relationship="description" withArrow>
<ToolbarButton
ref={saveButtonRef}
onClick={onSaveRequest}
aria-label={l10n.t('Save to the database')}
icon={<SaveRegular />}
Expand Down
19 changes: 18 additions & 1 deletion src/webviews/documentdb/documentView/documentView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<HTMLButtonElement>(null);

useSelectiveContextMenuPrevention();

// a useEffect without a dependency runs only once after the first render only
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 (
<div className="documentView">
<div className="toolbarContainer">
Expand All @@ -273,6 +288,7 @@ export const DocumentView = (): JSX.Element => {
onSaveRequest={handleOnSaveRequest}
onValidateRequest={handleOnValidateRequest}
onRefreshRequest={handleOnRefreshRequest}
saveButtonRef={saveButtonRef}
/>
</div>
<div className="monacoContainer">
Expand All @@ -283,6 +299,7 @@ export const DocumentView = (): JSX.Element => {
options={monacoOptions}
value={editorContent}
onMount={handleMonacoEditorMount}
onEscapeEditor={handleEscapeEditor}
onChange={() => {
setIsDirty(true);
}}
Expand Down