Skip to content

Commit 8874756

Browse files
committed
Add CodeEditor actions and improve Escape-Tab handling
1 parent 04b5dcb commit 8874756

8 files changed

Lines changed: 254 additions & 45 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p
99
### Added
1010

1111
- `<CodeEditor />`
12+
- adds the `actions` property for rendering custom actions to the right of the content inside the scrollable editor area
1213
- documents the built-in Escape then Tab sequence for moving focus out of the editor when Tab is configured to indent
1314
- shows a compact keyboard navigation hint in a bottom CodeMirror panel while the editor is focused and Tab is configured to indent
1415
- adds visually hidden navigation instructions for screen reader users

‎src/components/AutoSuggestion/AutoSuggestion.scss‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,9 @@
3131
background-color: $eccgui-color-textfield-background;
3232
}
3333

34-
.#{$eccgui}-singlelinecodeeditor {
35-
padding: 0;
34+
.#{$eccgui}-codeeditor.#{$eccgui}-singlelinecodeeditor {
35+
height: auto;
36+
padding: 2px;
3637

3738
[class^="cm-theme"] {
3839
width: 100%;
@@ -45,8 +46,7 @@
4546
}
4647

4748
.cm-editor {
48-
top: 1px;
49-
height: calc(#{$eccgui-size-textfield-height-regular} - 2px);
49+
height: auto;
5050
padding: 0;
5151
margin: 0;
5252
overflow: hidden;
@@ -61,11 +61,18 @@
6161

6262
.cm-scroller {
6363
height: 100%;
64+
min-height: calc(#{$eccgui-size-textfield-height-regular} - 2px);
6465
padding: 0;
6566
margin: 0;
6667
overflow: auto hidden !important;
6768
}
6869

70+
.#{$eccgui}-codeeditor__actions {
71+
align-items: center;
72+
padding: 0;
73+
border-left: 0;
74+
}
75+
6976
.cm-content {
7077
display: flex;
7178
flex-direction: column;

‎src/components/AutoSuggestion/AutoSuggestion.tsx‎

Lines changed: 55 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -198,9 +198,10 @@ export const CodeAutocompleteField = ({
198198
intent,
199199
}: CodeAutocompleteFieldProps) => {
200200
const value = React.useRef<string>(initialValue);
201+
const [hasValue, setHasValue] = React.useState(!!initialValue);
201202
const cursorPosition = React.useRef(0);
202203
const dropdownXYoffset = React.useRef<{ x: number; y: number }>({ x: 0, y: 0 });
203-
const [shouldShowDropdown, setShouldShowDropdown] = React.useState(false);
204+
const [shouldShowDropdown, setShouldShowDropdownState] = React.useState(false);
204205
const [suggestions, setSuggestions] = React.useState<CodeAutocompleteFieldSuggestionWithReplacementInfo[]>([]);
205206
const [suggestionsPending, setSuggestionsPending] = React.useState(false);
206207
const suggestionRequestData = React.useRef<RequestMetaData>({ requestId: undefined });
@@ -221,6 +222,7 @@ export const CodeAutocompleteField = ({
221222
const currentCm = React.useRef<EditorView>(undefined);
222223
currentCm.current = cm;
223224
const isFocused = React.useRef(false);
225+
const suggestionsDismissed = React.useRef(false);
224226
const autoSuggestionDivRef = React.useRef<HTMLDivElement>(null);
225227
/** Mutable editor state, since this needs to be current in scope of the SingleLineEditorComponent. */
226228
const [editorState] = React.useState<{
@@ -229,6 +231,15 @@ export const CodeAutocompleteField = ({
229231
cm?: EditorView;
230232
dropdownShown: boolean;
231233
}>({ index: 0, suggestions: [], dropdownShown: false });
234+
235+
const setDropdownShown = React.useCallback(
236+
(shown: boolean) => {
237+
editorState.dropdownShown = shown;
238+
setShouldShowDropdownState(shown);
239+
},
240+
[editorState],
241+
);
242+
232243
/** This is for the AutoSuggestionList component in order to re-render. */
233244
const [focusedIndex, setFocusedIndex] = React.useState(0);
234245
const selectedTextRanges = React.useRef<IRange[]>([]);
@@ -266,10 +277,6 @@ export const CodeAutocompleteField = ({
266277
typeof editorState?.cm?.dispatch === "function" ? editorState?.cm?.dispatch : () => {}
267278
) as EditorView["dispatch"];
268279

269-
React.useEffect(() => {
270-
editorState.dropdownShown = shouldShowDropdown;
271-
}, [shouldShowDropdown, editorState]);
272-
273280
// Handle replacement highlighting
274281
useEffect(() => {
275282
if (highlightedElement && cm) {
@@ -331,7 +338,7 @@ export const CodeAutocompleteField = ({
331338
suggestionResponse?.replacementResults?.length === 1 &&
332339
!suggestionResponse?.replacementResults[0]?.replacements?.length
333340
) {
334-
setShouldShowDropdown(false);
341+
setDropdownShown(false);
335342
}
336343
if (suggestionResponse?.replacementResults?.length) {
337344
suggestionResponse.replacementResults.forEach(
@@ -352,7 +359,7 @@ export const CodeAutocompleteField = ({
352359
setSuggestions([]);
353360
}
354361
setCurrentIndex(0);
355-
}, [suggestionResponse, editorState]);
362+
}, [suggestionResponse, editorState, setDropdownShown]);
356363

357364
const getOffsetRange = (cm: EditorView, from: number, to: number) => {
358365
if (!cm) return { fromOffset: 0, toOffset: 0 };
@@ -365,15 +372,6 @@ export const CodeAutocompleteField = ({
365372
return { fromOffset, toOffset };
366373
};
367374

368-
const inputActionsDisplayed = React.useCallback((node: any) => {
369-
if (!node) return;
370-
const width = node.offsetWidth;
371-
const slCodeEditor = node.parentElement.getElementsByClassName(`${eccgui}-singlelinecodeeditor`);
372-
if (slCodeEditor.length > 0) {
373-
slCodeEditor[0].style.paddingRight = `${width}px`;
374-
}
375-
}, []);
376-
377375
const asyncCheckInput = useMemo(
378376
() => async (inputString: string) => {
379377
if (
@@ -448,6 +446,7 @@ export const CodeAutocompleteField = ({
448446
const handleChange = React.useMemo(() => {
449447
return (val: string) => {
450448
value.current = val;
449+
setHasValue(!!val);
451450
checkValuePathValidity.cancel();
452451
checkValuePathValidity(value.current);
453452
onChange(val);
@@ -462,8 +461,8 @@ export const CodeAutocompleteField = ({
462461
cursorPosition.current = cursor - offsetFromFirstLine;
463462
// cursor change is fired after onChange, so we put the auto-complete logic here
464463
//get value at line
465-
if (isFocused.current) {
466-
setShouldShowDropdown(true);
464+
if (isFocused.current && !suggestionsDismissed.current) {
465+
setDropdownShown(true);
467466
handleEditorInputChange.cancel();
468467
handleEditorInputChange(value.current, cursorPosition.current);
469468
}
@@ -480,6 +479,20 @@ export const CodeAutocompleteField = ({
480479
};
481480

482481
const handleInputEditorKeyPress = (event: KeyboardEvent) => {
482+
if (event.key === OVERWRITTEN_KEYS.Escape) {
483+
if (editorState.dropdownShown) {
484+
suggestionsDismissed.current = true;
485+
486+
event.preventDefault();
487+
handleEscapePressed();
488+
}
489+
// A closed dropdown lets CodeMirror handle Escape so the next Tab can leave the editor.
490+
return true;
491+
}
492+
if (event.key === OVERWRITTEN_KEYS.Tab && !editorState.dropdownShown) {
493+
return true;
494+
}
495+
suggestionsDismissed.current = false;
483496
const overWrittenKeys: Array<string> = Object.values(OVERWRITTEN_KEYS);
484497
if (overWrittenKeys.includes(event.key) && (useTabForCompletions || event.key !== OVERWRITTEN_KEYS.Tab)) {
485498
//don't prevent when enter should create new line (multiline config) and dropdown isn't shown
@@ -498,7 +511,7 @@ export const CodeAutocompleteField = ({
498511

499512
const closeDropDown = () => {
500513
setHighlightedElement(undefined);
501-
setShouldShowDropdown(false);
514+
setDropdownShown(false);
502515
};
503516

504517
const handleDropdownChange = (selectedSuggestion: CodeAutocompleteFieldSuggestionWithReplacementInfo) => {
@@ -525,19 +538,20 @@ export const CodeAutocompleteField = ({
525538
}
526539
};
527540

528-
const handleInputEditorClear = () => {
529-
dispatch({
530-
changes: { from: 0, to: cm?.state.doc.length, insert: "" },
541+
const handleInputEditorClear = React.useCallback(() => {
542+
currentCm.current?.dispatch({
543+
changes: { from: 0, to: currentCm.current.state.doc.length, insert: "" },
531544
});
532545
cursorPosition.current = 0;
533546
handleChange("");
534-
cm?.focus();
535-
};
547+
currentCm.current?.focus();
548+
}, [handleChange]);
536549

537550
const handleInputFocus = (focusState: boolean) => {
538551
onFocusChange?.(focusState);
539552
if (focusState) {
540-
setShouldShowDropdown(true);
553+
suggestionsDismissed.current = false;
554+
setDropdownShown(true);
541555
} else {
542556
closeDropDown();
543557
}
@@ -556,6 +570,7 @@ export const CodeAutocompleteField = ({
556570
};
557571

558572
const handleInputMouseDown = React.useCallback((editor: EditorView) => {
573+
suggestionsDismissed.current = false;
559574
const cursor = editorState.cm?.state.selection.main.head;
560575
const currentLine = editorState.cm?.state.doc.lineAt(cursor ?? 0).number;
561576
const clickedLine = editor?.state.doc.lineAt(cursor ?? 0).number;
@@ -658,6 +673,17 @@ export const CodeAutocompleteField = ({
658673
onMouseDown={handleInputMouseDown}
659674
height={height}
660675
readOnly={readOnly}
676+
codeEditorProps={{
677+
actions: hasValue ? (
678+
<IconButton
679+
data-test-id="value-path-clear-btn"
680+
name="operation-clear"
681+
text={clearIconText}
682+
disabled={readOnly}
683+
onClick={handleInputEditorClear}
684+
/>
685+
) : undefined,
686+
}}
661687
/>
662688
);
663689
}, [
@@ -670,7 +696,11 @@ export const CodeAutocompleteField = ({
670696
showScrollBar,
671697
multiline,
672698
handleInputMouseDown,
699+
height,
673700
readOnly,
701+
hasValue,
702+
clearIconText,
703+
handleInputEditorClear,
674704
effectiveIntent,
675705
]);
676706
const autoSuggestionInput = (
@@ -708,17 +738,6 @@ export const CodeAutocompleteField = ({
708738
>
709739
{codeEditor}
710740
</ContextOverlay>
711-
{!!value.current && (
712-
<span className={BlueprintClassNames.INPUT_ACTION} ref={inputActionsDisplayed}>
713-
<IconButton
714-
data-test-id={"value-path-clear-btn"}
715-
name="operation-clear"
716-
text={clearIconText}
717-
disabled={readOnly}
718-
onClick={handleInputEditorClear}
719-
/>
720-
</span>
721-
)}
722741
</div>
723742
</div>
724743
);

‎src/components/AutoSuggestion/tests/AutoSuggestion.test.tsx‎

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import React from "react";
2-
import { render } from "@testing-library/react";
2+
import { EditorView } from "@codemirror/view";
3+
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
34

45
import "@testing-library/jest-dom";
56

@@ -49,4 +50,84 @@ describe("AutoSuggestion", () => {
4950
const { getByText } = render(<AutoSuggestion {...props} />);
5051
expect(getByText(props.label!)).toBeTruthy();
5152
});
53+
54+
it("renders and updates the clear action inside the editor scroller", async () => {
55+
const { container } = render(<AutoSuggestion {...props} initialValue="value" clearIconText="Clear value" />);
56+
const clearButton = container.querySelector<HTMLElement>("[data-test-id='value-path-clear-btn']");
57+
const actions = container.querySelector(".eccgui-codeeditor__actions");
58+
59+
expect(actions).toContainElement(clearButton);
60+
expect(actions?.parentElement).toHaveClass("cm-scroller");
61+
62+
fireEvent.click(clearButton!);
63+
64+
expect(props.onChange).toHaveBeenCalledWith("");
65+
await waitFor(() => expect(container.querySelector(".eccgui-codeeditor__actions")).toBeNull());
66+
});
67+
68+
it.each([false, true])("updates the container height (multiline: %s)", (multiline) => {
69+
const { container, rerender } = render(<AutoSuggestion {...props} multiline={multiline} height={120} />);
70+
const editorContainer = container.querySelector<HTMLElement>(".eccgui-codeeditor");
71+
expect(editorContainer).toHaveStyle({ height: "120px" });
72+
73+
rerender(<AutoSuggestion {...props} multiline={multiline} height="10rem" />);
74+
expect(editorContainer).toHaveStyle({ height: "10rem" });
75+
76+
rerender(<AutoSuggestion {...props} multiline={multiline} />);
77+
expect(editorContainer?.style.height).toBe("");
78+
});
79+
80+
it.each([
81+
{ multiline: false, useTabForCompletions: false },
82+
{ multiline: false, useTabForCompletions: true },
83+
{ multiline: true, useTabForCompletions: false },
84+
{ multiline: true, useTabForCompletions: true },
85+
])(
86+
"separates closing suggestions from Escape then Tab navigation ($multiline, $useTabForCompletions)",
87+
async ({ multiline, useTabForCompletions }) => {
88+
render(
89+
<AutoSuggestion
90+
{...props}
91+
initialValue="value"
92+
mode="json"
93+
multiline={multiline}
94+
useTabForCompletions={useTabForCompletions}
95+
autoCompletionRequestDelay={0}
96+
fetchSuggestions={(inputString, cursorPosition) => ({
97+
inputString,
98+
cursorPosition,
99+
replacementResults: [
100+
{
101+
replacementInterval: { from: 0, length: 5 },
102+
extractedQuery: "",
103+
replacements: [{ value: "completion" }],
104+
},
105+
],
106+
})}
107+
/>,
108+
);
109+
const editor = screen.getByRole("textbox");
110+
act(() => editor.focus());
111+
expect(await screen.findByText("completion")).toBeVisible();
112+
const view = EditorView.findFromDOM(editor);
113+
act(() => view?.dispatch({ selection: { anchor: 0, head: 5 } }));
114+
const initialContent = editor.textContent;
115+
116+
// With suggestions open, Escape only closes the dropdown.
117+
expect(fireEvent.keyDown(editor, { key: "Escape", code: "Escape", keyCode: 27 })).toBe(false);
118+
119+
await waitFor(() => expect(screen.queryByText("completion")).not.toBeInTheDocument());
120+
expect(editor).toHaveFocus();
121+
expect(editor.textContent).toBe(initialContent);
122+
123+
expect(fireEvent.keyDown(editor, { key: "Tab", code: "Tab", keyCode: 9 })).toBe(false);
124+
const contentAfterIndent = editor.textContent;
125+
expect(contentAfterIndent).not.toBe(initialContent);
126+
127+
// Once the dropdown is closed, Escape enables CodeMirror's temporary tab-focus mode.
128+
expect(fireEvent.keyDown(editor, { key: "Escape", code: "Escape", keyCode: 27 })).toBe(true);
129+
expect(fireEvent.keyDown(editor, { key: "Tab", code: "Tab", keyCode: 9 })).toBe(true);
130+
expect(editor.textContent).toBe(contentAfterIndent);
131+
},
132+
);
52133
});

‎src/extensions/codemirror/CodeMirror.stories.tsx‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@ import React from "react";
22
import { Meta, StoryFn } from "@storybook/react";
33

44
import { helpersArgTypes } from "../../../.storybook/helpers";
5+
import { Button } from "../../components/Button/Button";
6+
import { FieldItem } from "../../components/Form/FieldItem";
57

68
import { CodeEditor } from "./CodeMirror";
7-
import { FieldItem } from "../../components/Form/FieldItem";
89

910
export default {
1011
title: "Extensions/CodeEditor",
@@ -58,6 +59,13 @@ LongContent.args = {
5859
),
5960
};
6061

62+
export const WithActions = TemplateFull.bind({});
63+
WithActions.args = {
64+
...BasicExample.args,
65+
name: "editor-with-actions",
66+
actions: <Button>Run</Button>,
67+
};
68+
6169
export const MarkdownWithToolbar = TemplateFull.bind({});
6270
MarkdownWithToolbar.args = {
6371
name: "mdinput",

0 commit comments

Comments
 (0)