From e62b99e2b685f61bf46a44a3fd566c0c32445639 Mon Sep 17 00:00:00 2001 From: Mariia Kovsharova Date: Thu, 10 Sep 2026 14:37:37 +0200 Subject: [PATCH 1/6] Add shortcut to leave the code editor and a hint explaining it (CMEM-7687) --- CHANGELOG.md | 5 ++ src/extensions/codemirror/CodeMirror.tsx | 76 +++++++++++++------ .../codemirror/tests/CodeEditor.test.tsx | 54 ++++++++++++- 3 files changed, 112 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fe232e53..24e5eb53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ### Added +- `` + - `Ctrl+Tab` removes focus from the editor when Tab is configured to indent + - shows a keyboard navigation hint while the editor is focused and Tab is configured to indent; supports translation via `codeEditor.warning` with the `key` option - `` - `noDrag` parameter: Add the `nodrag` class to the Switch element. Default: `true` - `` @@ -67,6 +70,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ### Deprecated +- `` + - `enableTab`: use `tabIntentStyle` and `tabForceSpaceForModes` to configure Tab key behavior - `preventReactFlowActionsClasses`: use `ClassNames.ReactFlow.preventAllActions` ## [26.1.0] - 2026-08-20 diff --git a/src/extensions/codemirror/CodeMirror.tsx b/src/extensions/codemirror/CodeMirror.tsx index 325294d5..fd50d9bb 100644 --- a/src/extensions/codemirror/CodeMirror.tsx +++ b/src/extensions/codemirror/CodeMirror.tsx @@ -7,6 +7,7 @@ import { minimalSetup } from "codemirror"; import { Markdown } from "../../cmem/markdown/Markdown"; import { IntentTypes } from "../../common/Intent"; +import {FlexibleLayoutContainer, FlexibleLayoutItem, Notification} from "../../components"; import { markField } from "../../components/AutoSuggestion/extensions/markText"; import { TestableComponent } from "../../components/interfaces"; import { CLASSPREFIX as eccgui } from "../../configuration/constants"; @@ -146,7 +147,7 @@ export interface CodeEditorProps */ shouldHaveMinimalSetup?: boolean; /** - * If the key is enabled as normal input, i.e. it won't have the behavior of changing to the next input element, expected in a web app. + * @deprecated No longer affects Tab key behavior. Use `tabIntentStyle` and `tabForceSpaceForModes` instead. */ enableTab?: boolean; /** @@ -174,9 +175,20 @@ export interface CodeEditorProps /** * Get the translation for a specific key */ - translate?: (key: string) => string | false; + translate?: (key: string, options?: Record) => string | false; } +const BLUR_EDITOR_KEY = "Ctrl-Tab"; +const FALLBACK_WARNING = "Tab to indent. Ctrl+Tab to leave the editor."; + +const blurEditorKeyBinding: KeyBinding = { + key: BLUR_EDITOR_KEY, + run: (view: EditorView) => { + view.contentDOM.blur(); + return true; + }, +}; + const addExtensionsFor = (flag: boolean, ...extensions: Extension[]) => (flag ? [...extensions] : []); const addToKeyMapConfigFor = (flag: boolean, ...keys: KeyBinding[]) => (flag ? [...keys] : []); const addHandlersFor = (flag: boolean, handlerName: string, handler: any) => @@ -243,6 +255,7 @@ export const CodeEditor = ({ ...otherCodeEditorProps }: CodeEditorProps) => { const parent = useRef(undefined); + const [focused, setFocused] = React.useState(false); const [view, setView] = React.useState(); const defaultAppearanceForModeWithToolbar = getDefaultAppearanceForModeWithToolbar(useToolbar, mode); const [editorAppearance, setEditorAppearance] = React.useState<{ [s: string]: boolean }>({ @@ -308,22 +321,23 @@ export const CodeEditor = ({ } }; - const getTranslation = (key: string): string | false => { + const getTranslation = (key: string, options?: Record): string | false => { if (translate && typeof translate === "function") { - return translate(key); + return translate(key, options); } return false; }; + const modeRequiresSpaces = !!(mode && tabForceSpaceForModes?.includes(mode)); + const shouldIndentWithTab = tabIntentStyle === "tab" && !modeRequiresSpaces; + const createKeyMapConfigs = () => { - const tabIndent = - !!(tabIntentStyle === "tab" && mode && !(tabForceSpaceForModes ?? []).includes(mode)) || enableTab; return [ defaultKeymap as KeyBinding, ...addToKeyMapConfigFor(!shouldHaveMinimalSetup, ...historyKeymap), ...addToKeyMapConfigFor(supportCodeFolding, ...foldKeymap), - ...addToKeyMapConfigFor(tabIndent, indentWithTab), + ...addToKeyMapConfigFor(shouldIndentWithTab, indentWithTab, blurEditorKeyBinding), ]; }; @@ -352,8 +366,14 @@ export const CodeEditor = ({ "mousedown", (_: any, view: EditorView) => onMouseDown && onMouseDown(view), ), - ...addHandlersFor(!!onFocusChange, "blur", () => onFocusChange && onFocusChange(false)), - ...addHandlersFor(!!onFocusChange, "focus", () => onFocusChange && onFocusChange(true)), + blur: () => { + setFocused(false); + onFocusChange?.(false); + }, + focus: () => { + setFocused(true); + onFocusChange?.(true); + }, ...addHandlersFor(!!onKeyDown, "keydown", onKeyDownHandler), } as DOMEventHandlers; const extensions = [ @@ -599,19 +619,31 @@ export const CodeEditor = ({ }; return ( -
- {hasToolbarSupport && editorToolbar(mode)} -
+ + {focused && shouldIndentWithTab ? ( + + + + + ) : null} + + {hasToolbarSupport && editorToolbar(mode)} + + ); }; diff --git a/src/extensions/codemirror/tests/CodeEditor.test.tsx b/src/extensions/codemirror/tests/CodeEditor.test.tsx index 9a142bfe..0d129f5b 100644 --- a/src/extensions/codemirror/tests/CodeEditor.test.tsx +++ b/src/extensions/codemirror/tests/CodeEditor.test.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { fireEvent, render, screen } from "@testing-library/react"; +import { act, fireEvent, render, screen } from "@testing-library/react"; import "@testing-library/jest-dom"; @@ -136,3 +136,55 @@ describe("CodeEditor - markdown mode with toolbar", () => { expect(configMenuTrigger).toBeDisabled(); }); }); + +describe("CodeEditor - keyboard navigation hint", () => { + beforeAll(() => { + setupDocumentRange(); + }); + + it("shows the hint on focus with tab indentation in JSON and blurs on Ctrl+Tab", () => { + render(); + const editor = screen.getByRole("textbox"); + + expect(screen.queryByTestId("code-editor-warning")).not.toBeInTheDocument(); + + act(() => editor.focus()); + + expect(editor).toHaveFocus(); + expect(screen.getByTestId("code-editor-warning")).toBeVisible(); + + fireEvent.keyDown(editor, { key: "Tab", code: "Tab", keyCode: 9 }); + expect(editor).toHaveFocus(); + expect(screen.getByTestId("code-editor-warning")).toBeVisible(); + + fireEvent.keyDown(editor, { key: "Tab", code: "Tab", keyCode: 9, ctrlKey: true }); + + expect(editor).not.toHaveFocus(); + expect(screen.queryByTestId("code-editor-warning")).not.toBeInTheDocument(); + }); + + it("does not show the hint on focus with tab indentation in YAML", () => { + render(); + const editor = screen.getByRole("textbox"); + + act(() => editor.focus()); + + expect(editor).toHaveFocus(); + expect(screen.queryByTestId("code-editor-warning")).not.toBeInTheDocument(); + + fireEvent.keyDown(editor, { key: "Tab", code: "Tab", keyCode: 9 }); + expect(editor).toHaveFocus(); + expect(screen.queryByTestId("code-editor-warning")).not.toBeInTheDocument(); + }); + + it("does not show the hint on focus with space indentation", () => { + render(); + const editor = screen.getByRole("textbox"); + + act(() => editor.focus()); + + expect(editor).toHaveFocus(); + expect(screen.queryByTestId("code-editor-warning")).not.toBeInTheDocument(); + }); +}); + From 10f13fcab74ccd259ed0e746e64ae1d27e700007 Mon Sep 17 00:00:00 2001 From: Mariia Kovsharova Date: Thu, 10 Sep 2026 18:09:07 +0200 Subject: [PATCH 2/6] Added screen reader hint, added the native editor panel as notification info --- CHANGELOG.md | 8 +- .../codemirror/CodeMirror.stories.tsx | 24 ++++ src/extensions/codemirror/CodeMirror.tsx | 126 +++++++++++++----- src/extensions/codemirror/_codemirror.scss | 27 +++- .../codemirror/tests/CodeEditor.test.tsx | 63 ++++++++- 5 files changed, 203 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 24e5eb53..14b6b250 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,10 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ### Added - `` - - `Ctrl+Tab` removes focus from the editor when Tab is configured to indent - - shows a keyboard navigation hint while the editor is focused and Tab is configured to indent; supports translation via `codeEditor.warning` with the `key` option + - `Ctrl+.` removes focus from the editor when Tab is configured to indent + - shows a compact keyboard navigation hint in a bottom CodeMirror panel while the editor is focused and Tab is configured to indent + - adds visually hidden navigation instructions for screen reader users + - `keyboardHint` and `focusHint` accept custom elements for localized instructions, including their `lang` attributes; default instructions are marked as English - `` - `noDrag` parameter: Add the `nodrag` class to the Switch element. Default: `true` - `` @@ -71,7 +73,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ### Deprecated - `` - - `enableTab`: use `tabIntentStyle` and `tabForceSpaceForModes` to configure Tab key behavior + - `enableTab` no longer affects Tab key behavior; use `tabIntentStyle` and `tabForceSpaceForModes` to configure it - `preventReactFlowActionsClasses`: use `ClassNames.ReactFlow.preventAllActions` ## [26.1.0] - 2026-08-20 diff --git a/src/extensions/codemirror/CodeMirror.stories.tsx b/src/extensions/codemirror/CodeMirror.stories.tsx index b5e6e039..f7e1fba3 100644 --- a/src/extensions/codemirror/CodeMirror.stories.tsx +++ b/src/extensions/codemirror/CodeMirror.stories.tsx @@ -34,6 +34,30 @@ BasicExample.args = { defaultValue: '{ json: "true" }', }; +export const LongContent = TemplateFull.bind({}); +LongContent.args = { + name: "long-json-input", + mode: "json", + tabIntentStyle: "tab", + height: "20rem", + wrapLines: false, + defaultValue: JSON.stringify( + { + name: "Product catalog", + products: Array.from({ length: 30 }, (_, index) => ({ + id: `product-${index + 1}`, + name: `Product ${index + 1}`, + description: + "A detailed product description containing specifications, available options, delivery information, and care instructions. This intentionally long line makes it possible to check horizontal scrolling alongside the keyboard navigation hint.", + available: index % 3 !== 0, + tags: ["catalog", "featured", "online"], + })), + }, + null, + 2, + ), +}; + export const MarkdownWithToolbar = TemplateFull.bind({}); MarkdownWithToolbar.args = { name: "mdinput", diff --git a/src/extensions/codemirror/CodeMirror.tsx b/src/extensions/codemirror/CodeMirror.tsx index fd50d9bb..797f1b1a 100644 --- a/src/extensions/codemirror/CodeMirror.tsx +++ b/src/extensions/codemirror/CodeMirror.tsx @@ -1,13 +1,23 @@ import React, { useMemo, useRef } from "react"; +import { createPortal } from "react-dom"; import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands"; import { defaultHighlightStyle, foldKeymap } from "@codemirror/language"; import { Compartment, EditorState, Extension } from "@codemirror/state"; -import { DOMEventHandlers, EditorView, KeyBinding, keymap, Rect, ViewUpdate } from "@codemirror/view"; +import { + DOMEventHandlers, + EditorView, + KeyBinding, + keymap, + PanelConstructor, + Rect, + showPanel, + ViewUpdate, +} from "@codemirror/view"; import { minimalSetup } from "codemirror"; import { Markdown } from "../../cmem/markdown/Markdown"; import { IntentTypes } from "../../common/Intent"; -import {FlexibleLayoutContainer, FlexibleLayoutItem, Notification} from "../../components"; +import { ApplicationViewability, Icon } from "../../components"; import { markField } from "../../components/AutoSuggestion/extensions/markText"; import { TestableComponent } from "../../components/interfaces"; import { CLASSPREFIX as eccgui } from "../../configuration/constants"; @@ -175,11 +185,24 @@ export interface CodeEditorProps /** * Get the translation for a specific key */ - translate?: (key: string, options?: Record) => string | false; + translate?: (key: string) => string | false; + /** + * Custom keyboard navigation hint shown in the bottom panel while focused. + * Set `lang` on the element if its language differs from the surrounding page. + * Defaults to an English instruction for leaving the editor with Ctrl+. + */ + keyboardHint?: React.ReactElement; + /** + * Custom visually hidden instruction before the editing area for screen readers. + * Set `lang` on the element if its language differs from the surrounding page. + * Defaults to an English instruction for entering and leaving the editor. + */ + focusHint?: React.ReactElement; } -const BLUR_EDITOR_KEY = "Ctrl-Tab"; -const FALLBACK_WARNING = "Tab to indent. Ctrl+Tab to leave the editor."; +const BLUR_EDITOR_KEY = "Ctrl-."; +const FALLBACK_WARNING = "Ctrl+. to leave the editor."; +const FOCUS_HINT = "Focus the code editor field. Press Tab to enter. Press Ctrl+. to leave the editor."; const blurEditorKeyBinding: KeyBinding = { key: BLUR_EDITOR_KEY, @@ -252,10 +275,23 @@ export const CodeEditor = ({ intent, useToolbar = false, translate, + keyboardHint, + focusHint, ...otherCodeEditorProps }: CodeEditorProps) => { const parent = useRef(undefined); const [focused, setFocused] = React.useState(false); + const keyboardHintId = React.useId(); + const [keyboardHintPanel, setKeyboardHintPanel] = React.useState(null); + const createKeyboardHintPanel = React.useCallback((editorView) => { + const dom = editorView.dom.ownerDocument.createElement("div"); + return { + dom, + top: false, + mount: () => setKeyboardHintPanel(dom), + destroy: () => setKeyboardHintPanel(null), + }; + }, []); const [view, setView] = React.useState(); const defaultAppearanceForModeWithToolbar = getDefaultAppearanceForModeWithToolbar(useToolbar, mode); const [editorAppearance, setEditorAppearance] = React.useState<{ [s: string]: boolean }>({ @@ -283,6 +319,7 @@ export const CodeEditor = ({ const placeholderCompartment = React.useRef(compartment()); const modeCompartment = React.useRef(compartment()); const keyMapConfigsCompartment = React.useRef(compartment()); + const keyboardHintCompartment = React.useRef(compartment()); const tabIntentSizeCompartment = React.useRef(compartment()); const disabledCompartment = React.useRef(compartment()); const supportCodeFoldingCompartment = React.useRef(compartment()); @@ -321,9 +358,9 @@ export const CodeEditor = ({ } }; - const getTranslation = (key: string, options?: Record): string | false => { + const getTranslation = (key: string): string | false => { if (translate && typeof translate === "function") { - return translate(key, options); + return translate(key); } return false; @@ -331,6 +368,15 @@ export const CodeEditor = ({ const modeRequiresSpaces = !!(mode && tabForceSpaceForModes?.includes(mode)); const shouldIndentWithTab = tabIntentStyle === "tab" && !modeRequiresSpaces; + const keyboardHintExtension = useMemo( + () => + addExtensionsFor( + shouldIndentWithTab, + showPanel.of(createKeyboardHintPanel), + EditorView.contentAttributes.of({ "aria-describedby": keyboardHintId }), + ), + [shouldIndentWithTab, createKeyboardHintPanel, keyboardHintId], + ); const createKeyMapConfigs = () => { return [ @@ -383,6 +429,7 @@ export const CodeEditor = ({ adaptedHighlightSpecialChars(), modeCompartment.current.of(useCodeMirrorModeExtension(mode)), keyMapConfigsCompartment.current.of(keymap?.of(createKeyMapConfigs())), + keyboardHintCompartment.current.of(keyboardHintExtension), tabIntentSizeCompartment.current.of(EditorState?.tabSize.of(tabIntentSize)), readOnlyCompartment.current.of(EditorState?.readOnly.of(readOnly)), disabledCompartment.current.of(EditorView?.editable.of(!disabled)), @@ -451,10 +498,6 @@ export const CodeEditor = ({ setView(view); if (view?.dom) { - if (height) { - view.dom.style.height = typeof height === "string" ? height : `${height}px`; - } - if (disabled) { view.dom.classList.add(`${eccgui}-disabled`); } @@ -496,6 +539,10 @@ export const CodeEditor = ({ updateExtension(adaptedPlaceholder(placeholder), placeholderCompartment.current); }, [placeholder]); + React.useEffect(() => { + updateExtension(keyboardHintExtension, keyboardHintCompartment.current); + }, [keyboardHintExtension]); + React.useEffect(() => { updateExtension(useCodeMirrorModeExtension(mode), modeCompartment.current); }, [mode]); @@ -619,31 +666,40 @@ export const CodeEditor = ({ }; return ( - - {focused && shouldIndentWithTab ? ( - - - - +
+ {hasToolbarSupport && editorToolbar(mode)} + {shouldIndentWithTab ? ( + + {focusHint ?? FOCUS_HINT} + ) : null} - - {hasToolbarSupport && editorToolbar(mode)} - - + {shouldIndentWithTab && keyboardHintPanel + ? createPortal( +
+
, + keyboardHintPanel, + ) + : null} +
); }; diff --git a/src/extensions/codemirror/_codemirror.scss b/src/extensions/codemirror/_codemirror.scss index ae2e6c98..f3a03de8 100644 --- a/src/extensions/codemirror/_codemirror.scss +++ b/src/extensions/codemirror/_codemirror.scss @@ -5,13 +5,33 @@ $eccgui-color-codeeditor-background: $eccgui-color-textfield-background !default $eccgui-color-codeeditor-separation: $eccgui-color-separation-divider !default; $eccgui-size-codeeditor-height: 20rem !default; $eccgui-size-codeeditor-toolbar-height: $button-height !default; +$eccgui-size-codeeditor-keyboard-hint-height: 2rem !default; // adjustments // stylelint-disable selector-class-pattern .#{$eccgui}-codeeditor { position: relative; display: flex; + flex-direction: column; max-width: 100%; + height: $eccgui-size-codeeditor-height; + + &__keyboard-hint { + box-sizing: border-box; + display: flex; + gap: $eccgui-size-inline-whitespace; + align-items: center; + justify-content: flex-end; + min-height: $eccgui-size-codeeditor-keyboard-hint-height; + padding: 0.25rem $eccgui-size-inline-whitespace; + font-size: 0.875rem; + line-height: 1.4; + color: $eccgui-color-workspace-text; + + .#{$eccgui}-icon { + flex-shrink: 0; + } + } [class^="cm-theme"] { width: 100%; @@ -48,8 +68,10 @@ $eccgui-size-codeeditor-toolbar-height: $button-height !default; } .cm-editor { + box-sizing: border-box; + flex: 1; width: 100%; - height: $eccgui-size-codeeditor-height; + min-height: 0; background-color: $eccgui-color-codeeditor-background; border-radius: $pt-border-radius; @@ -60,7 +82,6 @@ $eccgui-size-codeeditor-toolbar-height: $button-height !default; &.#{eccgui}-disabled { @extend .#{$ns}-input, .#{$ns}-disabled; - height: $eccgui-size-codeeditor-height; padding: 0; } @@ -127,8 +148,10 @@ $eccgui-size-codeeditor-toolbar-height: $button-height !default; } .cm-scroller { + flex: 1; width: calc(100% - 2px); height: calc(100% - 2px); + min-height: 0; // fix size to prevent wrong calculation of other elements padding: 0; diff --git a/src/extensions/codemirror/tests/CodeEditor.test.tsx b/src/extensions/codemirror/tests/CodeEditor.test.tsx index 0d129f5b..3abd7b9b 100644 --- a/src/extensions/codemirror/tests/CodeEditor.test.tsx +++ b/src/extensions/codemirror/tests/CodeEditor.test.tsx @@ -142,11 +142,18 @@ describe("CodeEditor - keyboard navigation hint", () => { setupDocumentRange(); }); - it("shows the hint on focus with tab indentation in JSON and blurs on Ctrl+Tab", () => { + it("shows the hint on focus with tab indentation in JSON and blurs on Ctrl+Period", () => { render(); const editor = screen.getByRole("textbox"); + const focusHint = screen.getByText( + "Focus the code editor field. Press Tab to enter. Press Ctrl+. to leave the editor.", + ); - expect(screen.queryByTestId("code-editor-warning")).not.toBeInTheDocument(); + expect(screen.getByTestId("code-editor-warning")).not.toBeVisible(); + expect(focusHint).toHaveAttribute("lang", "en"); + expect(screen.getByText("Ctrl+. to leave the editor.")).toHaveAttribute("lang", "en"); + expect(screen.getByTestId("code-editor-warning").closest(".cm-panels-bottom")).not.toBeNull(); + expect(editor).toHaveAccessibleDescription("Ctrl+. to leave the editor."); act(() => editor.focus()); @@ -157,10 +164,10 @@ describe("CodeEditor - keyboard navigation hint", () => { expect(editor).toHaveFocus(); expect(screen.getByTestId("code-editor-warning")).toBeVisible(); - fireEvent.keyDown(editor, { key: "Tab", code: "Tab", keyCode: 9, ctrlKey: true }); + fireEvent.keyDown(editor, { key: ".", code: "Period", keyCode: 190, ctrlKey: true }); expect(editor).not.toHaveFocus(); - expect(screen.queryByTestId("code-editor-warning")).not.toBeInTheDocument(); + expect(screen.getByTestId("code-editor-warning")).not.toBeVisible(); }); it("does not show the hint on focus with tab indentation in YAML", () => { @@ -186,5 +193,51 @@ describe("CodeEditor - keyboard navigation hint", () => { expect(editor).toHaveFocus(); expect(screen.queryByTestId("code-editor-warning")).not.toBeInTheDocument(); }); -}); + it("updates the custom hint and removes the panel and description when Tab indentation is disabled", () => { + const { rerender } = render(); + const editor = screen.getByRole("textbox"); + + act(() => editor.focus()); + rerender( + Ctrl+. zum Verlassen des Editors.} + />, + ); + + expect(screen.getByTestId("code-editor-warning")).toHaveTextContent("Ctrl+. zum Verlassen des Editors."); + expect(editor).toHaveAccessibleDescription("Ctrl+. zum Verlassen des Editors."); + expect(screen.getByText("Ctrl+. zum Verlassen des Editors.")).toHaveAttribute("lang", "de"); + + rerender(); + + expect(screen.queryByTestId("code-editor-warning")).not.toBeInTheDocument(); + expect(editor).not.toHaveAttribute("aria-describedby"); + + rerender(); + + expect(screen.getByTestId("code-editor-warning")).toBeVisible(); + expect(editor).toHaveAccessibleDescription("Ctrl+. to leave the editor."); + }); + + it("preserves the language of custom screen reader and panel hints", () => { + render( + Appuyez sur Tab pour entrer dans l’éditeur.} + keyboardHint={Ctrl+. pour quitter l’éditeur.} + />, + ); + + expect(screen.getByText("Appuyez sur Tab pour entrer dans l’éditeur.")).toHaveAttribute("lang", "fr"); + expect(screen.getByText("Ctrl+. pour quitter l’éditeur.")).toHaveAttribute("lang", "fr"); + const editor = screen.getByRole("textbox"); + act(() => editor.focus()); + expect(editor).toHaveAccessibleDescription("Ctrl+. pour quitter l’éditeur."); + }); +}); From 04b5dcb02631d6f26cf2c028d391acada70ec748 Mon Sep 17 00:00:00 2001 From: Mariia Kovsharova Date: Mon, 14 Sep 2026 12:45:26 +0200 Subject: [PATCH 3/6] Set the default key binding to CodeEditor to leave the focused field --- CHANGELOG.md | 2 +- src/extensions/codemirror/CodeMirror.tsx | 15 ++---- .../codemirror/tests/CodeEditor.test.tsx | 48 ++++++++++++------- 3 files changed, 35 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 14b6b250..803e5997 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ### Added - `` - - `Ctrl+.` removes focus from the editor when Tab is configured to indent + - documents the built-in Escape then Tab sequence for moving focus out of the editor when Tab is configured to indent - shows a compact keyboard navigation hint in a bottom CodeMirror panel while the editor is focused and Tab is configured to indent - adds visually hidden navigation instructions for screen reader users - `keyboardHint` and `focusHint` accept custom elements for localized instructions, including their `lang` attributes; default instructions are marked as English diff --git a/src/extensions/codemirror/CodeMirror.tsx b/src/extensions/codemirror/CodeMirror.tsx index 797f1b1a..8389c44f 100644 --- a/src/extensions/codemirror/CodeMirror.tsx +++ b/src/extensions/codemirror/CodeMirror.tsx @@ -200,17 +200,8 @@ export interface CodeEditorProps focusHint?: React.ReactElement; } -const BLUR_EDITOR_KEY = "Ctrl-."; -const FALLBACK_WARNING = "Ctrl+. to leave the editor."; -const FOCUS_HINT = "Focus the code editor field. Press Tab to enter. Press Ctrl+. to leave the editor."; - -const blurEditorKeyBinding: KeyBinding = { - key: BLUR_EDITOR_KEY, - run: (view: EditorView) => { - view.contentDOM.blur(); - return true; - }, -}; +const FALLBACK_WARNING = "Press Escape then Tab to leave the editor."; +const FOCUS_HINT = "Focus the code editor field. Press Tab to enter. Press Escape then Tab to leave the editor."; const addExtensionsFor = (flag: boolean, ...extensions: Extension[]) => (flag ? [...extensions] : []); const addToKeyMapConfigFor = (flag: boolean, ...keys: KeyBinding[]) => (flag ? [...keys] : []); @@ -383,7 +374,7 @@ export const CodeEditor = ({ defaultKeymap as KeyBinding, ...addToKeyMapConfigFor(!shouldHaveMinimalSetup, ...historyKeymap), ...addToKeyMapConfigFor(supportCodeFolding, ...foldKeymap), - ...addToKeyMapConfigFor(shouldIndentWithTab, indentWithTab, blurEditorKeyBinding), + ...addToKeyMapConfigFor(shouldIndentWithTab, indentWithTab), ]; }; diff --git a/src/extensions/codemirror/tests/CodeEditor.test.tsx b/src/extensions/codemirror/tests/CodeEditor.test.tsx index 3abd7b9b..821dec1b 100644 --- a/src/extensions/codemirror/tests/CodeEditor.test.tsx +++ b/src/extensions/codemirror/tests/CodeEditor.test.tsx @@ -142,29 +142,42 @@ describe("CodeEditor - keyboard navigation hint", () => { setupDocumentRange(); }); - it("shows the hint on focus with tab indentation in JSON and blurs on Ctrl+Period", () => { - render(); + it("shows the hint on focus with tab indentation in JSON and releases Tab after Escape", () => { + render( + <> + + + , + ); const editor = screen.getByRole("textbox"); const focusHint = screen.getByText( - "Focus the code editor field. Press Tab to enter. Press Ctrl+. to leave the editor.", + "Focus the code editor field. Press Tab to enter. Press Escape then Tab to leave the editor.", ); expect(screen.getByTestId("code-editor-warning")).not.toBeVisible(); expect(focusHint).toHaveAttribute("lang", "en"); - expect(screen.getByText("Ctrl+. to leave the editor.")).toHaveAttribute("lang", "en"); + expect(screen.getByText("Press Escape then Tab to leave the editor.")).toHaveAttribute("lang", "en"); expect(screen.getByTestId("code-editor-warning").closest(".cm-panels-bottom")).not.toBeNull(); - expect(editor).toHaveAccessibleDescription("Ctrl+. to leave the editor."); + expect(editor).toHaveAccessibleDescription("Press Escape then Tab to leave the editor."); act(() => editor.focus()); expect(editor).toHaveFocus(); expect(screen.getByTestId("code-editor-warning")).toBeVisible(); - fireEvent.keyDown(editor, { key: "Tab", code: "Tab", keyCode: 9 }); + expect(fireEvent.keyDown(editor, { key: "Tab", code: "Tab", keyCode: 9 })).toBe(false); expect(editor).toHaveFocus(); expect(screen.getByTestId("code-editor-warning")).toBeVisible(); + const indentedContent = editor.textContent; + expect(indentedContent).not.toBe(""); + + fireEvent.keyDown(editor, { key: "Escape", code: "Escape", keyCode: 27 }); + expect(editor).toHaveFocus(); + expect(fireEvent.keyDown(editor, { key: "Tab", code: "Tab", keyCode: 9 })).toBe(true); + expect(editor.textContent).toBe(indentedContent); - fireEvent.keyDown(editor, { key: ".", code: "Period", keyCode: 190, ctrlKey: true }); + // jsdom does not perform the browser's native focus navigation for Tab. + act(() => screen.getByRole("button", { name: "Next field" }).focus()); expect(editor).not.toHaveFocus(); expect(screen.getByTestId("code-editor-warning")).not.toBeVisible(); @@ -179,8 +192,7 @@ describe("CodeEditor - keyboard navigation hint", () => { expect(editor).toHaveFocus(); expect(screen.queryByTestId("code-editor-warning")).not.toBeInTheDocument(); - fireEvent.keyDown(editor, { key: "Tab", code: "Tab", keyCode: 9 }); - expect(editor).toHaveFocus(); + expect(fireEvent.keyDown(editor, { key: "Tab", code: "Tab", keyCode: 9 })).toBe(true); expect(screen.queryByTestId("code-editor-warning")).not.toBeInTheDocument(); }); @@ -204,13 +216,15 @@ describe("CodeEditor - keyboard navigation hint", () => { name="test-editor" mode="json" tabIntentStyle="tab" - keyboardHint={Ctrl+. zum Verlassen des Editors.} + keyboardHint={Escape, dann Tab zum Verlassen des Editors.} />, ); - expect(screen.getByTestId("code-editor-warning")).toHaveTextContent("Ctrl+. zum Verlassen des Editors."); - expect(editor).toHaveAccessibleDescription("Ctrl+. zum Verlassen des Editors."); - expect(screen.getByText("Ctrl+. zum Verlassen des Editors.")).toHaveAttribute("lang", "de"); + expect(screen.getByTestId("code-editor-warning")).toHaveTextContent( + "Escape, dann Tab zum Verlassen des Editors.", + ); + expect(editor).toHaveAccessibleDescription("Escape, dann Tab zum Verlassen des Editors."); + expect(screen.getByText("Escape, dann Tab zum Verlassen des Editors.")).toHaveAttribute("lang", "de"); rerender(); @@ -220,7 +234,7 @@ describe("CodeEditor - keyboard navigation hint", () => { rerender(); expect(screen.getByTestId("code-editor-warning")).toBeVisible(); - expect(editor).toHaveAccessibleDescription("Ctrl+. to leave the editor."); + expect(editor).toHaveAccessibleDescription("Press Escape then Tab to leave the editor."); }); it("preserves the language of custom screen reader and panel hints", () => { @@ -230,14 +244,14 @@ describe("CodeEditor - keyboard navigation hint", () => { mode="json" tabIntentStyle="tab" focusHint={Appuyez sur Tab pour entrer dans l’éditeur.} - keyboardHint={Ctrl+. pour quitter l’éditeur.} + keyboardHint={Échap, puis Tab pour quitter l’éditeur.} />, ); expect(screen.getByText("Appuyez sur Tab pour entrer dans l’éditeur.")).toHaveAttribute("lang", "fr"); - expect(screen.getByText("Ctrl+. pour quitter l’éditeur.")).toHaveAttribute("lang", "fr"); + expect(screen.getByText("Échap, puis Tab pour quitter l’éditeur.")).toHaveAttribute("lang", "fr"); const editor = screen.getByRole("textbox"); act(() => editor.focus()); - expect(editor).toHaveAccessibleDescription("Ctrl+. pour quitter l’éditeur."); + expect(editor).toHaveAccessibleDescription("Échap, puis Tab pour quitter l’éditeur."); }); }); From 06705473feb7a542b5bd2e6b63a43da5acb36673 Mon Sep 17 00:00:00 2001 From: Mariia Kovsharova Date: Mon, 14 Sep 2026 16:20:44 +0200 Subject: [PATCH 4/6] Add CodeEditor actions and improve Escape-Tab handling --- CHANGELOG.md | 1 + .../AutoSuggestion/AutoSuggestion.scss | 15 +++- .../AutoSuggestion/AutoSuggestion.tsx | 89 +++++++++++-------- .../tests/AutoSuggestion.test.tsx | 83 ++++++++++++++++- .../codemirror/CodeMirror.stories.tsx | 10 ++- src/extensions/codemirror/CodeMirror.tsx | 41 ++++++++- src/extensions/codemirror/_codemirror.scss | 11 +++ .../codemirror/tests/CodeEditor.test.tsx | 47 ++++++++++ 8 files changed, 253 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 803e5997..d8cf0787 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ### Added - `` + - adds the `actions` property for rendering custom actions to the right of the content inside the scrollable editor area - documents the built-in Escape then Tab sequence for moving focus out of the editor when Tab is configured to indent - shows a compact keyboard navigation hint in a bottom CodeMirror panel while the editor is focused and Tab is configured to indent - adds visually hidden navigation instructions for screen reader users diff --git a/src/components/AutoSuggestion/AutoSuggestion.scss b/src/components/AutoSuggestion/AutoSuggestion.scss index 3d2297cf..43d75096 100644 --- a/src/components/AutoSuggestion/AutoSuggestion.scss +++ b/src/components/AutoSuggestion/AutoSuggestion.scss @@ -31,8 +31,9 @@ background-color: $eccgui-color-textfield-background; } -.#{$eccgui}-singlelinecodeeditor { - padding: 0; +.#{$eccgui}-codeeditor.#{$eccgui}-singlelinecodeeditor { + height: auto; + padding: 2px; [class^="cm-theme"] { width: 100%; @@ -45,8 +46,7 @@ } .cm-editor { - top: 1px; - height: calc(#{$eccgui-size-textfield-height-regular} - 2px); + height: auto; padding: 0; margin: 0; overflow: hidden; @@ -61,11 +61,18 @@ .cm-scroller { height: 100%; + min-height: calc(#{$eccgui-size-textfield-height-regular} - 2px); padding: 0; margin: 0; overflow: auto hidden !important; } + .#{$eccgui}-codeeditor__actions { + align-items: center; + padding: 0; + border-left: 0; + } + .cm-content { display: flex; flex-direction: column; diff --git a/src/components/AutoSuggestion/AutoSuggestion.tsx b/src/components/AutoSuggestion/AutoSuggestion.tsx index ff9a7f59..dfeb6ebb 100644 --- a/src/components/AutoSuggestion/AutoSuggestion.tsx +++ b/src/components/AutoSuggestion/AutoSuggestion.tsx @@ -198,6 +198,7 @@ export const CodeAutocompleteField = ({ intent, }: CodeAutocompleteFieldProps) => { const value = React.useRef(initialValue); + const [hasValue, setHasValue] = React.useState(!!initialValue); const cursorPosition = React.useRef(0); const dropdownXYoffset = React.useRef<{ x: number; y: number }>({ x: 0, y: 0 }); const [shouldShowDropdown, setShouldShowDropdown] = React.useState(false); @@ -221,6 +222,7 @@ export const CodeAutocompleteField = ({ const currentCm = React.useRef(undefined); currentCm.current = cm; const isFocused = React.useRef(false); + const suggestionsDismissed = React.useRef(false); const autoSuggestionDivRef = React.useRef(null); /** Mutable editor state, since this needs to be current in scope of the SingleLineEditorComponent. */ const [editorState] = React.useState<{ @@ -229,6 +231,15 @@ export const CodeAutocompleteField = ({ cm?: EditorView; dropdownShown: boolean; }>({ index: 0, suggestions: [], dropdownShown: false }); + + const setDropdownShown = React.useCallback( + (shown: boolean) => { + editorState.dropdownShown = shown; + setShouldShowDropdown(shown); + }, + [editorState], + ); + /** This is for the AutoSuggestionList component in order to re-render. */ const [focusedIndex, setFocusedIndex] = React.useState(0); const selectedTextRanges = React.useRef([]); @@ -266,10 +277,6 @@ export const CodeAutocompleteField = ({ typeof editorState?.cm?.dispatch === "function" ? editorState?.cm?.dispatch : () => {} ) as EditorView["dispatch"]; - React.useEffect(() => { - editorState.dropdownShown = shouldShowDropdown; - }, [shouldShowDropdown, editorState]); - // Handle replacement highlighting useEffect(() => { if (highlightedElement && cm) { @@ -331,7 +338,7 @@ export const CodeAutocompleteField = ({ suggestionResponse?.replacementResults?.length === 1 && !suggestionResponse?.replacementResults[0]?.replacements?.length ) { - setShouldShowDropdown(false); + setDropdownShown(false); } if (suggestionResponse?.replacementResults?.length) { suggestionResponse.replacementResults.forEach( @@ -352,7 +359,7 @@ export const CodeAutocompleteField = ({ setSuggestions([]); } setCurrentIndex(0); - }, [suggestionResponse, editorState]); + }, [suggestionResponse, editorState, setDropdownShown]); const getOffsetRange = (cm: EditorView, from: number, to: number) => { if (!cm) return { fromOffset: 0, toOffset: 0 }; @@ -365,15 +372,6 @@ export const CodeAutocompleteField = ({ return { fromOffset, toOffset }; }; - const inputActionsDisplayed = React.useCallback((node: any) => { - if (!node) return; - const width = node.offsetWidth; - const slCodeEditor = node.parentElement.getElementsByClassName(`${eccgui}-singlelinecodeeditor`); - if (slCodeEditor.length > 0) { - slCodeEditor[0].style.paddingRight = `${width}px`; - } - }, []); - const asyncCheckInput = useMemo( () => async (inputString: string) => { if ( @@ -448,6 +446,7 @@ export const CodeAutocompleteField = ({ const handleChange = React.useMemo(() => { return (val: string) => { value.current = val; + setHasValue(!!val); checkValuePathValidity.cancel(); checkValuePathValidity(value.current); onChange(val); @@ -462,8 +461,8 @@ export const CodeAutocompleteField = ({ cursorPosition.current = cursor - offsetFromFirstLine; // cursor change is fired after onChange, so we put the auto-complete logic here //get value at line - if (isFocused.current) { - setShouldShowDropdown(true); + if (isFocused.current && !suggestionsDismissed.current) { + setDropdownShown(true); handleEditorInputChange.cancel(); handleEditorInputChange(value.current, cursorPosition.current); } @@ -480,6 +479,20 @@ export const CodeAutocompleteField = ({ }; const handleInputEditorKeyPress = (event: KeyboardEvent) => { + if (event.key === OVERWRITTEN_KEYS.Escape) { + if (editorState.dropdownShown) { + suggestionsDismissed.current = true; + + event.preventDefault(); + handleEscapePressed(); + } + // A closed dropdown lets CodeMirror handle Escape so the next Tab can leave the editor. + return true; + } + if (event.key === OVERWRITTEN_KEYS.Tab && !editorState.dropdownShown) { + return true; + } + suggestionsDismissed.current = false; const overWrittenKeys: Array = Object.values(OVERWRITTEN_KEYS); if (overWrittenKeys.includes(event.key) && (useTabForCompletions || event.key !== OVERWRITTEN_KEYS.Tab)) { //don't prevent when enter should create new line (multiline config) and dropdown isn't shown @@ -498,7 +511,7 @@ export const CodeAutocompleteField = ({ const closeDropDown = () => { setHighlightedElement(undefined); - setShouldShowDropdown(false); + setDropdownShown(false); }; const handleDropdownChange = (selectedSuggestion: CodeAutocompleteFieldSuggestionWithReplacementInfo) => { @@ -525,19 +538,20 @@ export const CodeAutocompleteField = ({ } }; - const handleInputEditorClear = () => { - dispatch({ - changes: { from: 0, to: cm?.state.doc.length, insert: "" }, + const handleInputEditorClear = React.useCallback(() => { + currentCm.current?.dispatch({ + changes: { from: 0, to: currentCm.current.state.doc.length, insert: "" }, }); cursorPosition.current = 0; handleChange(""); - cm?.focus(); - }; + currentCm.current?.focus(); + }, [handleChange]); const handleInputFocus = (focusState: boolean) => { onFocusChange?.(focusState); if (focusState) { - setShouldShowDropdown(true); + suggestionsDismissed.current = false; + setDropdownShown(true); } else { closeDropDown(); } @@ -556,6 +570,7 @@ export const CodeAutocompleteField = ({ }; const handleInputMouseDown = React.useCallback((editor: EditorView) => { + suggestionsDismissed.current = false; const cursor = editorState.cm?.state.selection.main.head; const currentLine = editorState.cm?.state.doc.lineAt(cursor ?? 0).number; const clickedLine = editor?.state.doc.lineAt(cursor ?? 0).number; @@ -658,6 +673,17 @@ export const CodeAutocompleteField = ({ onMouseDown={handleInputMouseDown} height={height} readOnly={readOnly} + codeEditorProps={{ + actions: hasValue ? ( + + ) : undefined, + }} /> ); }, [ @@ -670,7 +696,11 @@ export const CodeAutocompleteField = ({ showScrollBar, multiline, handleInputMouseDown, + height, readOnly, + hasValue, + clearIconText, + handleInputEditorClear, effectiveIntent, ]); const autoSuggestionInput = ( @@ -708,17 +738,6 @@ export const CodeAutocompleteField = ({ > {codeEditor} - {!!value.current && ( - - - - )} ); diff --git a/src/components/AutoSuggestion/tests/AutoSuggestion.test.tsx b/src/components/AutoSuggestion/tests/AutoSuggestion.test.tsx index 6544a13b..07278e49 100644 --- a/src/components/AutoSuggestion/tests/AutoSuggestion.test.tsx +++ b/src/components/AutoSuggestion/tests/AutoSuggestion.test.tsx @@ -1,5 +1,6 @@ import React from "react"; -import { render } from "@testing-library/react"; +import { EditorView } from "@codemirror/view"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import "@testing-library/jest-dom"; @@ -49,4 +50,84 @@ describe("AutoSuggestion", () => { const { getByText } = render(); expect(getByText(props.label!)).toBeTruthy(); }); + + it("renders and updates the clear action inside the editor scroller", async () => { + const { container } = render(); + const clearButton = container.querySelector("[data-test-id='value-path-clear-btn']"); + const actions = container.querySelector(".eccgui-codeeditor__actions"); + + expect(actions).toContainElement(clearButton); + expect(actions?.parentElement).toHaveClass("cm-scroller"); + + fireEvent.click(clearButton!); + + expect(props.onChange).toHaveBeenCalledWith(""); + await waitFor(() => expect(container.querySelector(".eccgui-codeeditor__actions")).toBeNull()); + }); + + it.each([false, true])("updates the container height (multiline: %s)", (multiline) => { + const { container, rerender } = render(); + const editorContainer = container.querySelector(".eccgui-codeeditor"); + expect(editorContainer).toHaveStyle({ height: "120px" }); + + rerender(); + expect(editorContainer).toHaveStyle({ height: "10rem" }); + + rerender(); + expect(editorContainer?.style.height).toBe(""); + }); + + it.each([ + { multiline: false, useTabForCompletions: false }, + { multiline: false, useTabForCompletions: true }, + { multiline: true, useTabForCompletions: false }, + { multiline: true, useTabForCompletions: true }, + ])( + "separates closing suggestions from Escape then Tab navigation ($multiline, $useTabForCompletions)", + async ({ multiline, useTabForCompletions }) => { + render( + ({ + inputString, + cursorPosition, + replacementResults: [ + { + replacementInterval: { from: 0, length: 5 }, + extractedQuery: "", + replacements: [{ value: "completion" }], + }, + ], + })} + />, + ); + const editor = screen.getByRole("textbox"); + act(() => editor.focus()); + expect(await screen.findByText("completion")).toBeVisible(); + const view = EditorView.findFromDOM(editor); + act(() => view?.dispatch({ selection: { anchor: 0, head: 5 } })); + const initialContent = editor.textContent; + + // With suggestions open, Escape only closes the dropdown. + expect(fireEvent.keyDown(editor, { key: "Escape", code: "Escape", keyCode: 27 })).toBe(false); + + await waitFor(() => expect(screen.queryByText("completion")).not.toBeInTheDocument()); + expect(editor).toHaveFocus(); + expect(editor.textContent).toBe(initialContent); + + expect(fireEvent.keyDown(editor, { key: "Tab", code: "Tab", keyCode: 9 })).toBe(false); + const contentAfterIndent = editor.textContent; + expect(contentAfterIndent).not.toBe(initialContent); + + // Once the dropdown is closed, Escape enables CodeMirror's temporary tab-focus mode. + expect(fireEvent.keyDown(editor, { key: "Escape", code: "Escape", keyCode: 27 })).toBe(true); + expect(fireEvent.keyDown(editor, { key: "Tab", code: "Tab", keyCode: 9 })).toBe(true); + expect(editor.textContent).toBe(contentAfterIndent); + }, + ); }); diff --git a/src/extensions/codemirror/CodeMirror.stories.tsx b/src/extensions/codemirror/CodeMirror.stories.tsx index f7e1fba3..b61eb2c3 100644 --- a/src/extensions/codemirror/CodeMirror.stories.tsx +++ b/src/extensions/codemirror/CodeMirror.stories.tsx @@ -2,9 +2,10 @@ import React from "react"; import { Meta, StoryFn } from "@storybook/react"; import { helpersArgTypes } from "../../../.storybook/helpers"; +import { Button } from "../../components/Button/Button"; +import { FieldItem } from "../../components/Form/FieldItem"; import { CodeEditor } from "./CodeMirror"; -import { FieldItem } from "../../components/Form/FieldItem"; export default { title: "Extensions/CodeEditor", @@ -58,6 +59,13 @@ LongContent.args = { ), }; +export const WithActions = TemplateFull.bind({}); +WithActions.args = { + ...BasicExample.args, + name: "editor-with-actions", + actions: , +}; + export const MarkdownWithToolbar = TemplateFull.bind({}); MarkdownWithToolbar.args = { name: "mdinput", diff --git a/src/extensions/codemirror/CodeMirror.tsx b/src/extensions/codemirror/CodeMirror.tsx index 8389c44f..af95cd9e 100644 --- a/src/extensions/codemirror/CodeMirror.tsx +++ b/src/extensions/codemirror/CodeMirror.tsx @@ -2,7 +2,7 @@ import React, { useMemo, useRef } from "react"; import { createPortal } from "react-dom"; import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands"; import { defaultHighlightStyle, foldKeymap } from "@codemirror/language"; -import { Compartment, EditorState, Extension } from "@codemirror/state"; +import { Compartment, EditorState, Extension, Prec } from "@codemirror/state"; import { DOMEventHandlers, EditorView, @@ -11,6 +11,7 @@ import { PanelConstructor, Rect, showPanel, + ViewPlugin, ViewUpdate, } from "@codemirror/view"; import { minimalSetup } from "codemirror"; @@ -198,6 +199,8 @@ export interface CodeEditorProps * Defaults to an English instruction for entering and leaving the editor. */ focusHint?: React.ReactElement; + /** Actions displayed inside the scrollable editing area, to the right of the content. */ + actions?: React.ReactNode; } const FALLBACK_WARNING = "Press Escape then Tab to leave the editor."; @@ -268,12 +271,32 @@ export const CodeEditor = ({ translate, keyboardHint, focusHint, + actions, ...otherCodeEditorProps }: CodeEditorProps) => { + const hasActions = actions != null && actions !== false; const parent = useRef(undefined); const [focused, setFocused] = React.useState(false); const keyboardHintId = React.useId(); const [keyboardHintPanel, setKeyboardHintPanel] = React.useState(null); + const [actionsContainer, setActionsContainer] = React.useState(null); + const actionsPlugin = React.useMemo( + () => + ViewPlugin.define((editorView) => { + const dom = editorView.dom.ownerDocument.createElement("div"); + dom.className = `${eccgui}-codeeditor__actions`; + editorView.scrollDOM.appendChild(dom); + setActionsContainer(dom); + + return { + destroy: () => { + dom.remove(); + setActionsContainer((current) => (current === dom ? null : current)); + }, + }; + }), + [], + ); const createKeyboardHintPanel = React.useCallback((editorView) => { const dom = editorView.dom.ownerDocument.createElement("div"); return { @@ -311,6 +334,7 @@ export const CodeEditor = ({ const modeCompartment = React.useRef(compartment()); const keyMapConfigsCompartment = React.useRef(compartment()); const keyboardHintCompartment = React.useRef(compartment()); + const actionsCompartment = React.useRef(compartment()); const tabIntentSizeCompartment = React.useRef(compartment()); const disabledCompartment = React.useRef(compartment()); const supportCodeFoldingCompartment = React.useRef(compartment()); @@ -411,7 +435,6 @@ export const CodeEditor = ({ setFocused(true); onFocusChange?.(true); }, - ...addHandlersFor(!!onKeyDown, "keydown", onKeyDownHandler), } as DOMEventHandlers; const extensions = [ historyCompartment.current.of(addExtensionsFor(!shouldHaveMinimalSetup, history())), @@ -421,9 +444,16 @@ export const CodeEditor = ({ modeCompartment.current.of(useCodeMirrorModeExtension(mode)), keyMapConfigsCompartment.current.of(keymap?.of(createKeyMapConfigs())), keyboardHintCompartment.current.of(keyboardHintExtension), + actionsCompartment.current.of(addExtensionsFor(hasActions, actionsPlugin)), tabIntentSizeCompartment.current.of(EditorState?.tabSize.of(tabIntentSize)), readOnlyCompartment.current.of(EditorState?.readOnly.of(readOnly)), disabledCompartment.current.of(EditorView?.editable.of(!disabled)), + // Run the consumer's keydown handler before CodeMirror keymaps. A built-in binding such as + // Escape's simplifySelection may otherwise consume the event before autocomplete can close its dropdown. + ...addExtensionsFor( + !!onKeyDown, + Prec.highest(AdaptedEditorViewDomEventHandlers({ keydown: onKeyDownHandler }) as Extension), + ), AdaptedEditorViewDomEventHandlers(domEventHandlers) as Extension, EditorView?.updateListener.of((v: ViewUpdate) => { if (currentDisabled.current) return; @@ -444,7 +474,7 @@ export const CodeEditor = ({ syncIntentClass(v.view, currentIntent.current); } - if (onCursorChange) { + if (onCursorChange && (v.selectionSet || v.docChanged)) { const cursorPosition = v.state.selection.main.head ?? 0; const editorRect = v.view.dom.getBoundingClientRect(); const coords = v.view.coordsAtPos(cursorPosition), @@ -522,6 +552,10 @@ export const CodeEditor = ({ } }; + React.useEffect(() => { + updateExtension(addExtensionsFor(hasActions, actionsPlugin), actionsCompartment.current); + }, [hasActions, actionsPlugin]); + React.useEffect(() => { updateExtension(EditorState?.readOnly.of(readOnly!), readOnlyCompartment.current); }, [readOnly]); @@ -690,6 +724,7 @@ export const CodeEditor = ({ keyboardHintPanel, ) : null} + {actionsContainer && hasActions ? createPortal(actions, actionsContainer) : null} ); }; diff --git a/src/extensions/codemirror/_codemirror.scss b/src/extensions/codemirror/_codemirror.scss index f3a03de8..984cf649 100644 --- a/src/extensions/codemirror/_codemirror.scss +++ b/src/extensions/codemirror/_codemirror.scss @@ -33,6 +33,17 @@ $eccgui-size-codeeditor-keyboard-hint-height: 2rem !default; } } + &__actions { + box-sizing: border-box; + display: flex; + flex: 0 0 auto; + gap: $eccgui-size-inline-whitespace; + align-items: flex-start; + align-self: stretch; + padding: $eccgui-size-inline-whitespace; + background-color: $eccgui-color-codeeditor-background; + } + [class^="cm-theme"] { width: 100%; } diff --git a/src/extensions/codemirror/tests/CodeEditor.test.tsx b/src/extensions/codemirror/tests/CodeEditor.test.tsx index 821dec1b..50f5d637 100644 --- a/src/extensions/codemirror/tests/CodeEditor.test.tsx +++ b/src/extensions/codemirror/tests/CodeEditor.test.tsx @@ -255,3 +255,50 @@ describe("CodeEditor - keyboard navigation hint", () => { expect(editor).toHaveAccessibleDescription("Échap, puis Tab pour quitter l’éditeur."); }); }); + +describe("CodeEditor - actions", () => { + beforeAll(setupDocumentRange); + + it("renders actions inside the scroller while keeping the bottom panel across the editor", () => { + const onAction = jest.fn(); + const setEditorView = jest.fn(); + const { container, rerender } = render( + Run} + />, + ); + + const actionsContainer = container.querySelector(`.${eccgui}-codeeditor__actions`); + const scroller = container.querySelector(".cm-scroller"); + const editor = container.querySelector(".cm-editor"); + const bottomPanel = container.querySelector(".cm-panels-bottom"); + const setEditorViewCallsAfterMount = setEditorView.mock.calls.length; + + expect(scroller).toContainElement(actionsContainer as HTMLElement); + expect(actionsContainer?.parentElement).toBe(scroller); + expect(bottomPanel?.parentElement).toBe(editor); + expect(bottomPanel).not.toContainElement(actionsContainer as HTMLElement); + + fireEvent.click(screen.getByRole("button", { name: "Run" })); + expect(onAction).toHaveBeenCalledTimes(1); + + rerender( + Save} + />, + ); + expect(screen.queryByRole("button", { name: "Run" })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Save" })).toBeVisible(); + expect(container.querySelector(`.${eccgui}-codeeditor__actions`)).toBe(actionsContainer); + + rerender(); + expect(container.querySelector(`.${eccgui}-codeeditor__actions`)).toBeNull(); + expect(setEditorView).toHaveBeenCalledTimes(setEditorViewCallsAfterMount); + }); +}); From b8a00d579813877e70d5232cd723d013bd4ecb74 Mon Sep 17 00:00:00 2001 From: Mariia Kovsharova Date: Wed, 16 Sep 2026 16:27:45 +0200 Subject: [PATCH 5/6] Fixed comments issues --- CHANGELOG.md | 6 +- .../AutoSuggestion/AutoSuggestion.scss | 6 - .../AutoSuggestion/AutoSuggestion.tsx | 36 ++--- .../tests/AutoSuggestion.test.tsx | 14 -- .../codemirror/CodeMirror.stories.tsx | 12 +- src/extensions/codemirror/CodeMirror.tsx | 83 ++++------- src/extensions/codemirror/_codemirror.scss | 28 ++-- .../codemirror/tests/CodeEditor.test.tsx | 134 ++++++++---------- 8 files changed, 122 insertions(+), 197 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d8cf0787..77136069 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,11 +9,9 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ### Added - `` - - adds the `actions` property for rendering custom actions to the right of the content inside the scrollable editor area - documents the built-in Escape then Tab sequence for moving focus out of the editor when Tab is configured to indent - shows a compact keyboard navigation hint in a bottom CodeMirror panel while the editor is focused and Tab is configured to indent - - adds visually hidden navigation instructions for screen reader users - - `keyboardHint` and `focusHint` accept custom elements for localized instructions, including their `lang` attributes; default instructions are marked as English + - `keyboardHint` accepts a custom element for localized instructions, including its `lang` attribute; the default instruction is marked as English - `` - `noDrag` parameter: Add the `nodrag` class to the Switch element. Default: `true` - `` @@ -73,8 +71,6 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ### Deprecated -- `` - - `enableTab` no longer affects Tab key behavior; use `tabIntentStyle` and `tabForceSpaceForModes` to configure it - `preventReactFlowActionsClasses`: use `ClassNames.ReactFlow.preventAllActions` ## [26.1.0] - 2026-08-20 diff --git a/src/components/AutoSuggestion/AutoSuggestion.scss b/src/components/AutoSuggestion/AutoSuggestion.scss index 43d75096..03965246 100644 --- a/src/components/AutoSuggestion/AutoSuggestion.scss +++ b/src/components/AutoSuggestion/AutoSuggestion.scss @@ -67,12 +67,6 @@ overflow: auto hidden !important; } - .#{$eccgui}-codeeditor__actions { - align-items: center; - padding: 0; - border-left: 0; - } - .cm-content { display: flex; flex-direction: column; diff --git a/src/components/AutoSuggestion/AutoSuggestion.tsx b/src/components/AutoSuggestion/AutoSuggestion.tsx index dfeb6ebb..89b76b4d 100644 --- a/src/components/AutoSuggestion/AutoSuggestion.tsx +++ b/src/components/AutoSuggestion/AutoSuggestion.tsx @@ -198,7 +198,6 @@ export const CodeAutocompleteField = ({ intent, }: CodeAutocompleteFieldProps) => { const value = React.useRef(initialValue); - const [hasValue, setHasValue] = React.useState(!!initialValue); const cursorPosition = React.useRef(0); const dropdownXYoffset = React.useRef<{ x: number; y: number }>({ x: 0, y: 0 }); const [shouldShowDropdown, setShouldShowDropdown] = React.useState(false); @@ -372,6 +371,15 @@ export const CodeAutocompleteField = ({ return { fromOffset, toOffset }; }; + const inputActionsDisplayed = React.useCallback((node: any) => { + if (!node) return; + const width = node.offsetWidth; + const slCodeEditor = node.parentElement.getElementsByClassName(`${eccgui}-singlelinecodeeditor`); + if (slCodeEditor.length > 0) { + slCodeEditor[0].style.paddingRight = `${width}px`; + } + }, []); + const asyncCheckInput = useMemo( () => async (inputString: string) => { if ( @@ -446,7 +454,6 @@ export const CodeAutocompleteField = ({ const handleChange = React.useMemo(() => { return (val: string) => { value.current = val; - setHasValue(!!val); checkValuePathValidity.cancel(); checkValuePathValidity(value.current); onChange(val); @@ -673,17 +680,6 @@ export const CodeAutocompleteField = ({ onMouseDown={handleInputMouseDown} height={height} readOnly={readOnly} - codeEditorProps={{ - actions: hasValue ? ( - - ) : undefined, - }} /> ); }, [ @@ -698,9 +694,6 @@ export const CodeAutocompleteField = ({ handleInputMouseDown, height, readOnly, - hasValue, - clearIconText, - handleInputEditorClear, effectiveIntent, ]); const autoSuggestionInput = ( @@ -738,6 +731,17 @@ export const CodeAutocompleteField = ({ > {codeEditor} + {!!value.current && ( + + + + )} ); diff --git a/src/components/AutoSuggestion/tests/AutoSuggestion.test.tsx b/src/components/AutoSuggestion/tests/AutoSuggestion.test.tsx index 07278e49..936484bd 100644 --- a/src/components/AutoSuggestion/tests/AutoSuggestion.test.tsx +++ b/src/components/AutoSuggestion/tests/AutoSuggestion.test.tsx @@ -51,20 +51,6 @@ describe("AutoSuggestion", () => { expect(getByText(props.label!)).toBeTruthy(); }); - it("renders and updates the clear action inside the editor scroller", async () => { - const { container } = render(); - const clearButton = container.querySelector("[data-test-id='value-path-clear-btn']"); - const actions = container.querySelector(".eccgui-codeeditor__actions"); - - expect(actions).toContainElement(clearButton); - expect(actions?.parentElement).toHaveClass("cm-scroller"); - - fireEvent.click(clearButton!); - - expect(props.onChange).toHaveBeenCalledWith(""); - await waitFor(() => expect(container.querySelector(".eccgui-codeeditor__actions")).toBeNull()); - }); - it.each([false, true])("updates the container height (multiline: %s)", (multiline) => { const { container, rerender } = render(); const editorContainer = container.querySelector(".eccgui-codeeditor"); diff --git a/src/extensions/codemirror/CodeMirror.stories.tsx b/src/extensions/codemirror/CodeMirror.stories.tsx index b61eb2c3..ce995ee3 100644 --- a/src/extensions/codemirror/CodeMirror.stories.tsx +++ b/src/extensions/codemirror/CodeMirror.stories.tsx @@ -2,7 +2,6 @@ import React from "react"; import { Meta, StoryFn } from "@storybook/react"; import { helpersArgTypes } from "../../../.storybook/helpers"; -import { Button } from "../../components/Button/Button"; import { FieldItem } from "../../components/Form/FieldItem"; import { CodeEditor } from "./CodeMirror"; @@ -23,8 +22,8 @@ export default { let forcedUpdateKey = 0; // @see https://github.com/storybookjs/storybook/issues/13375#issuecomment-1291011856 const TemplateFull: StoryFn = (args) => ( - - + + ); @@ -59,13 +58,6 @@ LongContent.args = { ), }; -export const WithActions = TemplateFull.bind({}); -WithActions.args = { - ...BasicExample.args, - name: "editor-with-actions", - actions: , -}; - export const MarkdownWithToolbar = TemplateFull.bind({}); MarkdownWithToolbar.args = { name: "mdinput", diff --git a/src/extensions/codemirror/CodeMirror.tsx b/src/extensions/codemirror/CodeMirror.tsx index af95cd9e..c2dc5a34 100644 --- a/src/extensions/codemirror/CodeMirror.tsx +++ b/src/extensions/codemirror/CodeMirror.tsx @@ -11,14 +11,13 @@ import { PanelConstructor, Rect, showPanel, - ViewPlugin, ViewUpdate, } from "@codemirror/view"; import { minimalSetup } from "codemirror"; import { Markdown } from "../../cmem/markdown/Markdown"; import { IntentTypes } from "../../common/Intent"; -import { ApplicationViewability, Icon } from "../../components"; +import { Icon } from "../../components"; import { markField } from "../../components/AutoSuggestion/extensions/markText"; import { TestableComponent } from "../../components/interfaces"; import { CLASSPREFIX as eccgui } from "../../configuration/constants"; @@ -48,6 +47,7 @@ import { import { EditorAppearanceConfigMenu } from "./toolbars/EditorAppearanceConfigMenu"; import { MarkdownToolbar } from "./toolbars/markdown.toolbar"; import { ExtensionCreator } from "./types"; +import classNames from "classnames"; interface EditorAppearance { /** @@ -158,7 +158,7 @@ export interface CodeEditorProps */ shouldHaveMinimalSetup?: boolean; /** - * @deprecated No longer affects Tab key behavior. Use `tabIntentStyle` and `tabForceSpaceForModes` instead. + * If enabled, Tab is handled as editor input instead of moving focus to the next element. */ enableTab?: boolean; /** @@ -193,18 +193,9 @@ export interface CodeEditorProps * Defaults to an English instruction for leaving the editor with Ctrl+. */ keyboardHint?: React.ReactElement; - /** - * Custom visually hidden instruction before the editing area for screen readers. - * Set `lang` on the element if its language differs from the surrounding page. - * Defaults to an English instruction for entering and leaving the editor. - */ - focusHint?: React.ReactElement; - /** Actions displayed inside the scrollable editing area, to the right of the content. */ - actions?: React.ReactNode; } -const FALLBACK_WARNING = "Press Escape then Tab to leave the editor."; -const FOCUS_HINT = "Focus the code editor field. Press Tab to enter. Press Escape then Tab to leave the editor."; +const DEFAULT_BLUR_HINT = "Press Escape then Tab to leave the editor."; const addExtensionsFor = (flag: boolean, ...extensions: Extension[]) => (flag ? [...extensions] : []); const addToKeyMapConfigFor = (flag: boolean, ...keys: KeyBinding[]) => (flag ? [...keys] : []); @@ -261,7 +252,7 @@ export const CodeEditor = ({ placeholder, additionalExtensions = [], tabForceSpaceForModes = ["python", "yaml"], - enableTab = false, + enableTab: shouldIndentOnTab = false, height, useLinting = false, autoFocus = false, @@ -270,35 +261,15 @@ export const CodeEditor = ({ useToolbar = false, translate, keyboardHint, - focusHint, - actions, ...otherCodeEditorProps }: CodeEditorProps) => { - const hasActions = actions != null && actions !== false; const parent = useRef(undefined); const [focused, setFocused] = React.useState(false); const keyboardHintId = React.useId(); const [keyboardHintPanel, setKeyboardHintPanel] = React.useState(null); - const [actionsContainer, setActionsContainer] = React.useState(null); - const actionsPlugin = React.useMemo( - () => - ViewPlugin.define((editorView) => { - const dom = editorView.dom.ownerDocument.createElement("div"); - dom.className = `${eccgui}-codeeditor__actions`; - editorView.scrollDOM.appendChild(dom); - setActionsContainer(dom); - - return { - destroy: () => { - dom.remove(); - setActionsContainer((current) => (current === dom ? null : current)); - }, - }; - }), - [], - ); const createKeyboardHintPanel = React.useCallback((editorView) => { const dom = editorView.dom.ownerDocument.createElement("div"); + dom.className = `${eccgui}-codeeditor__footer`; return { dom, top: false, @@ -334,7 +305,6 @@ export const CodeEditor = ({ const modeCompartment = React.useRef(compartment()); const keyMapConfigsCompartment = React.useRef(compartment()); const keyboardHintCompartment = React.useRef(compartment()); - const actionsCompartment = React.useRef(compartment()); const tabIntentSizeCompartment = React.useRef(compartment()); const disabledCompartment = React.useRef(compartment()); const supportCodeFoldingCompartment = React.useRef(compartment()); @@ -382,15 +352,15 @@ export const CodeEditor = ({ }; const modeRequiresSpaces = !!(mode && tabForceSpaceForModes?.includes(mode)); - const shouldIndentWithTab = tabIntentStyle === "tab" && !modeRequiresSpaces; + const handlesTabAsIndentation = !!(tabIntentStyle === "tab" && mode && !modeRequiresSpaces) || shouldIndentOnTab; const keyboardHintExtension = useMemo( () => addExtensionsFor( - shouldIndentWithTab, + handlesTabAsIndentation, showPanel.of(createKeyboardHintPanel), EditorView.contentAttributes.of({ "aria-describedby": keyboardHintId }), ), - [shouldIndentWithTab, createKeyboardHintPanel, keyboardHintId], + [handlesTabAsIndentation, createKeyboardHintPanel, keyboardHintId], ); const createKeyMapConfigs = () => { @@ -398,7 +368,7 @@ export const CodeEditor = ({ defaultKeymap as KeyBinding, ...addToKeyMapConfigFor(!shouldHaveMinimalSetup, ...historyKeymap), ...addToKeyMapConfigFor(supportCodeFolding, ...foldKeymap), - ...addToKeyMapConfigFor(shouldIndentWithTab, indentWithTab), + ...addToKeyMapConfigFor(handlesTabAsIndentation, indentWithTab), ]; }; @@ -444,7 +414,6 @@ export const CodeEditor = ({ modeCompartment.current.of(useCodeMirrorModeExtension(mode)), keyMapConfigsCompartment.current.of(keymap?.of(createKeyMapConfigs())), keyboardHintCompartment.current.of(keyboardHintExtension), - actionsCompartment.current.of(addExtensionsFor(hasActions, actionsPlugin)), tabIntentSizeCompartment.current.of(EditorState?.tabSize.of(tabIntentSize)), readOnlyCompartment.current.of(EditorState?.readOnly.of(readOnly)), disabledCompartment.current.of(EditorView?.editable.of(!disabled)), @@ -552,10 +521,6 @@ export const CodeEditor = ({ } }; - React.useEffect(() => { - updateExtension(addExtensionsFor(hasActions, actionsPlugin), actionsCompartment.current); - }, [hasActions, actionsPlugin]); - React.useEffect(() => { updateExtension(EditorState?.readOnly.of(readOnly!), readOnlyCompartment.current); }, [readOnly]); @@ -579,7 +544,7 @@ export const CodeEditor = ({ mode, tabIntentStyle, (tabForceSpaceForModes ?? []).join(", "), - enableTab, + shouldIndentOnTab, shouldHaveMinimalSetup, ]); @@ -704,27 +669,31 @@ export const CodeEditor = ({ } > {hasToolbarSupport && editorToolbar(mode)} - {shouldIndentWithTab ? ( - - {focusHint ?? FOCUS_HINT} - - ) : null} - {shouldIndentWithTab && keyboardHintPanel + {handlesTabAsIndentation && keyboardHintPanel ? createPortal(
, keyboardHintPanel, ) : null} - {actionsContainer && hasActions ? createPortal(actions, actionsContainer) : null} ); }; diff --git a/src/extensions/codemirror/_codemirror.scss b/src/extensions/codemirror/_codemirror.scss index 984cf649..72420a1c 100644 --- a/src/extensions/codemirror/_codemirror.scss +++ b/src/extensions/codemirror/_codemirror.scss @@ -5,7 +5,6 @@ $eccgui-color-codeeditor-background: $eccgui-color-textfield-background !default $eccgui-color-codeeditor-separation: $eccgui-color-separation-divider !default; $eccgui-size-codeeditor-height: 20rem !default; $eccgui-size-codeeditor-toolbar-height: $button-height !default; -$eccgui-size-codeeditor-keyboard-hint-height: 2rem !default; // adjustments // stylelint-disable selector-class-pattern @@ -16,34 +15,29 @@ $eccgui-size-codeeditor-keyboard-hint-height: 2rem !default; max-width: 100%; height: $eccgui-size-codeeditor-height; - &__keyboard-hint { + &__footer-content { box-sizing: border-box; display: flex; + visibility: hidden; gap: $eccgui-size-inline-whitespace; align-items: center; justify-content: flex-end; - min-height: $eccgui-size-codeeditor-keyboard-hint-height; - padding: 0.25rem $eccgui-size-inline-whitespace; - font-size: 0.875rem; - line-height: 1.4; + font-size: $eccgui-size-typo-caption; + line-height: $eccgui-size-typo-caption-lineheight; color: $eccgui-color-workspace-text; + max-height: 0; + + &--visible { + visibility: visible; + padding: 0.5 * $eccgui-size-inline-whitespace; + max-height: unset; + } .#{$eccgui}-icon { flex-shrink: 0; } } - &__actions { - box-sizing: border-box; - display: flex; - flex: 0 0 auto; - gap: $eccgui-size-inline-whitespace; - align-items: flex-start; - align-self: stretch; - padding: $eccgui-size-inline-whitespace; - background-color: $eccgui-color-codeeditor-background; - } - [class^="cm-theme"] { width: 100%; } diff --git a/src/extensions/codemirror/tests/CodeEditor.test.tsx b/src/extensions/codemirror/tests/CodeEditor.test.tsx index 50f5d637..b2af9fa4 100644 --- a/src/extensions/codemirror/tests/CodeEditor.test.tsx +++ b/src/extensions/codemirror/tests/CodeEditor.test.tsx @@ -138,6 +138,10 @@ describe("CodeEditor - markdown mode with toolbar", () => { }); describe("CodeEditor - keyboard navigation hint", () => { + const editorTestId = "test-editor"; + const footerTestId = `${editorTestId}-footer`; + const footerVisibleClass = `${eccgui}-codeeditor__footer-content--visible`; + beforeAll(() => { setupDocumentRange(); }); @@ -145,29 +149,26 @@ describe("CodeEditor - keyboard navigation hint", () => { it("shows the hint on focus with tab indentation in JSON and releases Tab after Escape", () => { render( <> - + , ); const editor = screen.getByRole("textbox"); - const focusHint = screen.getByText( - "Focus the code editor field. Press Tab to enter. Press Escape then Tab to leave the editor.", - ); - expect(screen.getByTestId("code-editor-warning")).not.toBeVisible(); - expect(focusHint).toHaveAttribute("lang", "en"); - expect(screen.getByText("Press Escape then Tab to leave the editor.")).toHaveAttribute("lang", "en"); - expect(screen.getByTestId("code-editor-warning").closest(".cm-panels-bottom")).not.toBeNull(); - expect(editor).toHaveAccessibleDescription("Press Escape then Tab to leave the editor."); + expect(screen.getByTestId(footerTestId)).not.toHaveClass(footerVisibleClass); act(() => editor.focus()); expect(editor).toHaveFocus(); - expect(screen.getByTestId("code-editor-warning")).toBeVisible(); + expect(screen.getByTestId(footerTestId)).toHaveClass(footerVisibleClass); + expect(screen.getByText("Press Escape then Tab to leave the editor.")).toHaveAttribute("lang", "en"); + expect(screen.getByTestId(footerTestId).closest(".cm-panels-bottom")).not.toBeNull(); + expect(screen.getByTestId(footerTestId).closest(`.${eccgui}-codeeditor__footer`)).toHaveClass("cm-panel"); + expect(editor).toHaveAccessibleDescription("Press Escape then Tab to leave the editor."); expect(fireEvent.keyDown(editor, { key: "Tab", code: "Tab", keyCode: 9 })).toBe(false); expect(editor).toHaveFocus(); - expect(screen.getByTestId("code-editor-warning")).toBeVisible(); + expect(screen.getByTestId(footerTestId)).toHaveClass(footerVisibleClass); const indentedContent = editor.textContent; expect(indentedContent).not.toBe(""); @@ -180,39 +181,66 @@ describe("CodeEditor - keyboard navigation hint", () => { act(() => screen.getByRole("button", { name: "Next field" }).focus()); expect(editor).not.toHaveFocus(); - expect(screen.getByTestId("code-editor-warning")).not.toBeVisible(); + expect(screen.getByTestId(footerTestId)).not.toHaveClass(footerVisibleClass); }); it("does not show the hint on focus with tab indentation in YAML", () => { - render(); + render(); const editor = screen.getByRole("textbox"); act(() => editor.focus()); expect(editor).toHaveFocus(); - expect(screen.queryByTestId("code-editor-warning")).not.toBeInTheDocument(); + expect(screen.queryByTestId(footerTestId)).not.toBeInTheDocument(); expect(fireEvent.keyDown(editor, { key: "Tab", code: "Tab", keyCode: 9 })).toBe(true); - expect(screen.queryByTestId("code-editor-warning")).not.toBeInTheDocument(); + expect(screen.queryByTestId(footerTestId)).not.toBeInTheDocument(); }); it("does not show the hint on focus with space indentation", () => { - render(); + render(); const editor = screen.getByRole("textbox"); act(() => editor.focus()); expect(editor).toHaveFocus(); - expect(screen.queryByTestId("code-editor-warning")).not.toBeInTheDocument(); + expect(screen.queryByTestId(footerTestId)).not.toBeInTheDocument(); + }); + + it("handles Tab as indentation when enableTab is set", () => { + render( + , + ); + const editor = screen.getByRole("textbox"); + + act(() => editor.focus()); + + expect(screen.getByTestId(footerTestId)).toBeVisible(); + expect(fireEvent.keyDown(editor, { key: "Tab", code: "Tab", keyCode: 9 })).toBe(false); + expect(editor.textContent).not.toBe(""); + }); + + it("leaves Tab available for focus navigation without a mode or enableTab", () => { + render(); + const editor = screen.getByRole("textbox"); + + act(() => editor.focus()); + + expect(screen.queryByTestId(footerTestId)).not.toBeInTheDocument(); + expect(fireEvent.keyDown(editor, { key: "Tab", code: "Tab", keyCode: 9 })).toBe(true); + expect(editor.textContent).toBe(""); }); it("updates the custom hint and removes the panel and description when Tab indentation is disabled", () => { - const { rerender } = render(); + const { rerender } = render( + , + ); const editor = screen.getByRole("textbox"); act(() => editor.focus()); rerender( { />, ); - expect(screen.getByTestId("code-editor-warning")).toHaveTextContent( - "Escape, dann Tab zum Verlassen des Editors.", - ); + expect(screen.getByTestId(footerTestId)).toHaveTextContent("Escape, dann Tab zum Verlassen des Editors."); expect(editor).toHaveAccessibleDescription("Escape, dann Tab zum Verlassen des Editors."); expect(screen.getByText("Escape, dann Tab zum Verlassen des Editors.")).toHaveAttribute("lang", "de"); - rerender(); + rerender(); - expect(screen.queryByTestId("code-editor-warning")).not.toBeInTheDocument(); + expect(screen.queryByTestId(footerTestId)).not.toBeInTheDocument(); expect(editor).not.toHaveAttribute("aria-describedby"); - rerender(); + rerender(); - expect(screen.getByTestId("code-editor-warning")).toBeVisible(); + expect(screen.getByTestId(footerTestId)).toBeVisible(); expect(editor).toHaveAccessibleDescription("Press Escape then Tab to leave the editor."); }); - it("preserves the language of custom screen reader and panel hints", () => { + it("preserves the language of a custom panel hint", () => { render( Appuyez sur Tab pour entrer dans l’éditeur.} keyboardHint={Échap, puis Tab pour quitter l’éditeur.} />, ); - expect(screen.getByText("Appuyez sur Tab pour entrer dans l’éditeur.")).toHaveAttribute("lang", "fr"); - expect(screen.getByText("Échap, puis Tab pour quitter l’éditeur.")).toHaveAttribute("lang", "fr"); const editor = screen.getByRole("textbox"); act(() => editor.focus()); + expect(screen.getByText("Échap, puis Tab pour quitter l’éditeur.")).toHaveAttribute("lang", "fr"); expect(editor).toHaveAccessibleDescription("Échap, puis Tab pour quitter l’éditeur."); }); -}); -describe("CodeEditor - actions", () => { - beforeAll(setupDocumentRange); - - it("renders actions inside the scroller while keeping the bottom panel across the editor", () => { - const onAction = jest.fn(); - const setEditorView = jest.fn(); - const { container, rerender } = render( - Run} - />, - ); - - const actionsContainer = container.querySelector(`.${eccgui}-codeeditor__actions`); - const scroller = container.querySelector(".cm-scroller"); - const editor = container.querySelector(".cm-editor"); - const bottomPanel = container.querySelector(".cm-panels-bottom"); - const setEditorViewCallsAfterMount = setEditorView.mock.calls.length; - - expect(scroller).toContainElement(actionsContainer as HTMLElement); - expect(actionsContainer?.parentElement).toBe(scroller); - expect(bottomPanel?.parentElement).toBe(editor); - expect(bottomPanel).not.toContainElement(actionsContainer as HTMLElement); - - fireEvent.click(screen.getByRole("button", { name: "Run" })); - expect(onAction).toHaveBeenCalledTimes(1); + it("does not create a footer test ID when the editor has none", () => { + render(); + const editor = screen.getByRole("textbox"); - rerender( - Save} - />, - ); - expect(screen.queryByRole("button", { name: "Run" })).not.toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Save" })).toBeVisible(); - expect(container.querySelector(`.${eccgui}-codeeditor__actions`)).toBe(actionsContainer); + act(() => editor.focus()); - rerender(); - expect(container.querySelector(`.${eccgui}-codeeditor__actions`)).toBeNull(); - expect(setEditorView).toHaveBeenCalledTimes(setEditorViewCallsAfterMount); + const footerContent = screen + .getByText("Press Escape then Tab to leave the editor.") + .closest(`.${eccgui}-codeeditor__footer-content`); + expect(footerContent).not.toHaveAttribute("data-testid"); + expect(footerContent).not.toHaveAttribute("data-test-id"); }); }); From a4e5d86841ddd3959d615cdc8c6f9c3bf6ad8d80 Mon Sep 17 00:00:00 2001 From: Mariia Kovsharova Date: Wed, 16 Sep 2026 17:20:36 +0200 Subject: [PATCH 6/6] Adjust style issues --- src/extensions/codemirror/_codemirror.scss | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/extensions/codemirror/_codemirror.scss b/src/extensions/codemirror/_codemirror.scss index 72420a1c..611e5713 100644 --- a/src/extensions/codemirror/_codemirror.scss +++ b/src/extensions/codemirror/_codemirror.scss @@ -15,6 +15,16 @@ $eccgui-size-codeeditor-toolbar-height: $button-height !default; max-width: 100%; height: $eccgui-size-codeeditor-height; + .cm-panels.cm-panels-bottom:has(> .#{$eccgui}-codeeditor__footer) { + border-radius: 0 0 $pt-border-radius $pt-border-radius; + } + + .cm-panels.cm-panels-bottom:has(> .#{$eccgui}-codeeditor__footer):not( + :has(.#{$eccgui}-codeeditor__footer-content--visible) + ) { + border-top: 0; + } + &__footer-content { box-sizing: border-box; display: flex; @@ -22,15 +32,15 @@ $eccgui-size-codeeditor-toolbar-height: $button-height !default; gap: $eccgui-size-inline-whitespace; align-items: center; justify-content: flex-end; + max-height: 0; font-size: $eccgui-size-typo-caption; line-height: $eccgui-size-typo-caption-lineheight; color: $eccgui-color-workspace-text; - max-height: 0; &--visible { visibility: visible; - padding: 0.5 * $eccgui-size-inline-whitespace; max-height: unset; + padding: 0.5 * $eccgui-size-inline-whitespace; } .#{$eccgui}-icon {