;
const extensions = [
historyCompartment.current.of(addExtensionsFor(!shouldHaveMinimalSetup, history())),
@@ -363,9 +413,16 @@ 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)),
+ // 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;
@@ -386,7 +443,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),
@@ -431,10 +488,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`);
}
@@ -476,6 +529,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]);
@@ -487,7 +544,7 @@ export const CodeEditor = ({
mode,
tabIntentStyle,
(tabForceSpaceForModes ?? []).join(", "),
- enableTab,
+ shouldIndentOnTab,
shouldHaveMinimalSetup,
]);
@@ -604,6 +661,7 @@ export const CodeEditor = ({
// overwrite/extend some attributes
id={id ? id : name ? `codemirror-${name}` : undefined}
ref={parent}
+ style={{ ...otherCodeEditorProps.style, height: height ?? otherCodeEditorProps.style?.height }}
className={
`${eccgui}-codeeditor ${eccgui}-codeeditor--mode-${mode}` +
(className ? ` ${className}` : "") +
@@ -611,6 +669,31 @@ export const CodeEditor = ({
}
>
{hasToolbarSupport && editorToolbar(mode)}
+ {handlesTabAsIndentation && keyboardHintPanel
+ ? createPortal(
+
+
+
+ {keyboardHint ?? DEFAULT_BLUR_HINT}
+
+
,
+ keyboardHintPanel,
+ )
+ : null}
);
};
diff --git a/src/extensions/codemirror/_codemirror.scss b/src/extensions/codemirror/_codemirror.scss
index ae2e6c98..611e5713 100644
--- a/src/extensions/codemirror/_codemirror.scss
+++ b/src/extensions/codemirror/_codemirror.scss
@@ -11,7 +11,42 @@ $eccgui-size-codeeditor-toolbar-height: $button-height !default;
.#{$eccgui}-codeeditor {
position: relative;
display: flex;
+ flex-direction: column;
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;
+ visibility: hidden;
+ 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;
+
+ &--visible {
+ visibility: visible;
+ max-height: unset;
+ padding: 0.5 * $eccgui-size-inline-whitespace;
+ }
+
+ .#{$eccgui}-icon {
+ flex-shrink: 0;
+ }
+ }
[class^="cm-theme"] {
width: 100%;
@@ -48,8 +83,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 +97,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 +163,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 9a142bfe..b2af9fa4 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,159 @@ describe("CodeEditor - markdown mode with toolbar", () => {
expect(configMenuTrigger).toBeDisabled();
});
});
+
+describe("CodeEditor - keyboard navigation hint", () => {
+ const editorTestId = "test-editor";
+ const footerTestId = `${editorTestId}-footer`;
+ const footerVisibleClass = `${eccgui}-codeeditor__footer-content--visible`;
+
+ beforeAll(() => {
+ setupDocumentRange();
+ });
+
+ it("shows the hint on focus with tab indentation in JSON and releases Tab after Escape", () => {
+ render(
+ <>
+
+
+ >,
+ );
+ const editor = screen.getByRole("textbox");
+
+ expect(screen.getByTestId(footerTestId)).not.toHaveClass(footerVisibleClass);
+
+ act(() => editor.focus());
+
+ expect(editor).toHaveFocus();
+ 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(footerTestId)).toHaveClass(footerVisibleClass);
+ 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);
+
+ // 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(footerTestId)).not.toHaveClass(footerVisibleClass);
+ });
+
+ 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(footerTestId)).not.toBeInTheDocument();
+
+ expect(fireEvent.keyDown(editor, { key: "Tab", code: "Tab", keyCode: 9 })).toBe(true);
+ expect(screen.queryByTestId(footerTestId)).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(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 editor = screen.getByRole("textbox");
+
+ act(() => editor.focus());
+ rerender(
+ Escape, dann Tab zum Verlassen des Editors.