diff --git a/CHANGELOG.md b/CHANGELOG.md index fe232e53..77136069 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ### Added +- `` + - 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 + - `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` - `` diff --git a/src/components/AutoSuggestion/AutoSuggestion.scss b/src/components/AutoSuggestion/AutoSuggestion.scss index 3d2297cf..03965246 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,6 +61,7 @@ .cm-scroller { height: 100%; + min-height: calc(#{$eccgui-size-textfield-height-regular} - 2px); padding: 0; margin: 0; overflow: auto hidden !important; diff --git a/src/components/AutoSuggestion/AutoSuggestion.tsx b/src/components/AutoSuggestion/AutoSuggestion.tsx index ff9a7f59..89b76b4d 100644 --- a/src/components/AutoSuggestion/AutoSuggestion.tsx +++ b/src/components/AutoSuggestion/AutoSuggestion.tsx @@ -221,6 +221,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 +230,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 +276,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 +337,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 +358,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 }; @@ -462,8 +468,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 +486,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 +518,7 @@ export const CodeAutocompleteField = ({ const closeDropDown = () => { setHighlightedElement(undefined); - setShouldShowDropdown(false); + setDropdownShown(false); }; const handleDropdownChange = (selectedSuggestion: CodeAutocompleteFieldSuggestionWithReplacementInfo) => { @@ -525,19 +545,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 +577,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; @@ -670,6 +692,7 @@ export const CodeAutocompleteField = ({ showScrollBar, multiline, handleInputMouseDown, + height, readOnly, effectiveIntent, ]); @@ -711,7 +734,7 @@ export const CodeAutocompleteField = ({ {!!value.current && ( { const { getByText } = render(); expect(getByText(props.label!)).toBeTruthy(); }); + + 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 b5e6e039..ce995ee3 100644 --- a/src/extensions/codemirror/CodeMirror.stories.tsx +++ b/src/extensions/codemirror/CodeMirror.stories.tsx @@ -2,9 +2,9 @@ import React from "react"; import { Meta, StoryFn } from "@storybook/react"; import { helpersArgTypes } from "../../../.storybook/helpers"; +import { FieldItem } from "../../components/Form/FieldItem"; import { CodeEditor } from "./CodeMirror"; -import { FieldItem } from "../../components/Form/FieldItem"; export default { title: "Extensions/CodeEditor", @@ -22,8 +22,8 @@ export default { let forcedUpdateKey = 0; // @see https://github.com/storybookjs/storybook/issues/13375#issuecomment-1291011856 const TemplateFull: StoryFn = (args) => ( - - + + ); @@ -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 325294d5..c2dc5a34 100644 --- a/src/extensions/codemirror/CodeMirror.tsx +++ b/src/extensions/codemirror/CodeMirror.tsx @@ -1,12 +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 { Compartment, EditorState, Extension, Prec } from "@codemirror/state"; +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 { Icon } from "../../components"; import { markField } from "../../components/AutoSuggestion/extensions/markText"; import { TestableComponent } from "../../components/interfaces"; import { CLASSPREFIX as eccgui } from "../../configuration/constants"; @@ -36,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 { /** @@ -146,7 +158,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. + * If enabled, Tab is handled as editor input instead of moving focus to the next element. */ enableTab?: boolean; /** @@ -175,8 +187,16 @@ export interface CodeEditorProps * Get the translation for a specific key */ 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; } +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] : []); const addHandlersFor = (flag: boolean, handlerName: string, handler: any) => @@ -232,7 +252,7 @@ export const CodeEditor = ({ placeholder, additionalExtensions = [], tabForceSpaceForModes = ["python", "yaml"], - enableTab = false, + enableTab: shouldIndentOnTab = false, height, useLinting = false, autoFocus = false, @@ -240,9 +260,23 @@ export const CodeEditor = ({ intent, useToolbar = false, translate, + keyboardHint, ...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"); + dom.className = `${eccgui}-codeeditor__footer`; + 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 }>({ @@ -270,6 +304,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()); @@ -316,14 +351,24 @@ export const CodeEditor = ({ return false; }; + const modeRequiresSpaces = !!(mode && tabForceSpaceForModes?.includes(mode)); + const handlesTabAsIndentation = !!(tabIntentStyle === "tab" && mode && !modeRequiresSpaces) || shouldIndentOnTab; + const keyboardHintExtension = useMemo( + () => + addExtensionsFor( + handlesTabAsIndentation, + showPanel.of(createKeyboardHintPanel), + EditorView.contentAttributes.of({ "aria-describedby": keyboardHintId }), + ), + [handlesTabAsIndentation, createKeyboardHintPanel, keyboardHintId], + ); + 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(handlesTabAsIndentation, indentWithTab), ]; }; @@ -352,9 +397,14 @@ export const CodeEditor = ({ "mousedown", (_: any, view: EditorView) => onMouseDown && onMouseDown(view), ), - ...addHandlersFor(!!onFocusChange, "blur", () => onFocusChange && onFocusChange(false)), - ...addHandlersFor(!!onFocusChange, "focus", () => onFocusChange && onFocusChange(true)), - ...addHandlersFor(!!onKeyDown, "keydown", onKeyDownHandler), + blur: () => { + setFocused(false); + onFocusChange?.(false); + }, + focus: () => { + setFocused(true); + onFocusChange?.(true); + }, } as DOMEventHandlers; 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( +
+
, + 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.
} + />, + ); + + 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(); + + expect(screen.queryByTestId(footerTestId)).not.toBeInTheDocument(); + expect(editor).not.toHaveAttribute("aria-describedby"); + + rerender(); + + expect(screen.getByTestId(footerTestId)).toBeVisible(); + expect(editor).toHaveAccessibleDescription("Press Escape then Tab to leave the editor."); + }); + + it("preserves the language of a custom panel hint", () => { + render( + Échap, puis Tab pour quitter l’éditeur.} + />, + ); + + 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."); + }); + + it("does not create a footer test ID when the editor has none", () => { + render(); + const editor = screen.getByRole("textbox"); + + act(() => editor.focus()); + + 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"); + }); +});