Skip to content
Merged
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p

### Added

- `<CodeEditor />`
- 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 when Tab is configured to indent
- `keyboardHint` accepts a custom string or element for localized instructions, the default instruction is marked as English
- `<Switch />`
- `noDrag` parameter: Add the `nodrag` class to the Switch element. Default: `true`
- `<Markdown />`
Expand Down
9 changes: 5 additions & 4 deletions src/components/AutoSuggestion/AutoSuggestion.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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%;
Expand All @@ -45,8 +46,7 @@
}

.cm-editor {
top: 1px;
height: calc(#{$eccgui-size-textfield-height-regular} - 2px);
height: auto;
padding: 0;
margin: 0;
overflow: hidden;
Expand All @@ -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;
Expand Down
55 changes: 39 additions & 16 deletions src/components/AutoSuggestion/AutoSuggestion.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,7 @@ export const CodeAutocompleteField = ({
const currentCm = React.useRef<EditorView>(undefined);
currentCm.current = cm;
const isFocused = React.useRef(false);
const suggestionsDismissed = React.useRef(false);
const autoSuggestionDivRef = React.useRef<HTMLDivElement>(null);
/** Mutable editor state, since this needs to be current in scope of the SingleLineEditorComponent. */
const [editorState] = React.useState<{
Expand All @@ -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<IRange[]>([]);
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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(
Expand All @@ -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 };
Expand Down Expand Up @@ -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);
}
Expand All @@ -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<string> = 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
Expand All @@ -498,7 +518,7 @@ export const CodeAutocompleteField = ({

const closeDropDown = () => {
setHighlightedElement(undefined);
setShouldShowDropdown(false);
setDropdownShown(false);
};

const handleDropdownChange = (selectedSuggestion: CodeAutocompleteFieldSuggestionWithReplacementInfo) => {
Expand All @@ -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();
}
Expand All @@ -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;
Expand Down Expand Up @@ -670,6 +692,7 @@ export const CodeAutocompleteField = ({
showScrollBar,
multiline,
handleInputMouseDown,
height,
readOnly,
effectiveIntent,
]);
Expand Down Expand Up @@ -711,7 +734,7 @@ export const CodeAutocompleteField = ({
{!!value.current && (
<span className={BlueprintClassNames.INPUT_ACTION} ref={inputActionsDisplayed}>
<IconButton
data-test-id={"value-path-clear-btn"}
data-test-id="value-path-clear-btn"
name="operation-clear"
text={clearIconText}
disabled={readOnly}
Expand Down
69 changes: 68 additions & 1 deletion src/components/AutoSuggestion/tests/AutoSuggestion.test.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -49,4 +50,70 @@ describe("AutoSuggestion", () => {
const { getByText } = render(<AutoSuggestion {...props} />);
expect(getByText(props.label!)).toBeTruthy();
});

it.each([false, true])("updates the container height (multiline: %s)", (multiline) => {
const { container, rerender } = render(<AutoSuggestion {...props} multiline={multiline} height={120} />);
const editorContainer = container.querySelector<HTMLElement>(".eccgui-codeeditor");
expect(editorContainer).toHaveStyle({ height: "120px" });

rerender(<AutoSuggestion {...props} multiline={multiline} height="10rem" />);
expect(editorContainer).toHaveStyle({ height: "10rem" });

rerender(<AutoSuggestion {...props} multiline={multiline} />);
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(
<AutoSuggestion
{...props}
initialValue="value"
mode="json"
multiline={multiline}
useTabForCompletions={useTabForCompletions}
autoCompletionRequestDelay={0}
fetchSuggestions={(inputString, cursorPosition) => ({
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);
},
);
});
30 changes: 27 additions & 3 deletions src/extensions/codemirror/CodeMirror.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -22,8 +22,8 @@ export default {

let forcedUpdateKey = 0; // @see https://github.com/storybookjs/storybook/issues/13375#issuecomment-1291011856
const TemplateFull: StoryFn<typeof CodeEditor> = (args) => (
<FieldItem labelProps={{ text: "Code input", hidden: true }} key={++forcedUpdateKey} >
<CodeEditor {...args}/>
<FieldItem labelProps={{ text: "Code input", hidden: true }} key={++forcedUpdateKey}>
<CodeEditor {...args} />
</FieldItem>
);

Expand All @@ -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",
Expand Down
Loading
Loading