Skip to content

Commit bc61028

Browse files
authored
[LG-5445] feat(code-editor): add keyboard shortcuts help modal (#3098)
* feat(Panel): Add shortcut menu functionality * fix(Panel): update modal open state to use shortcutsModalOpen variable - Changed the modal's open state from a hardcoded true to the shortcutsModalOpen variable, allowing for dynamic control of the modal's visibility based on user interactions. * feat(ShortcutMenu): Move package * feat(CodeEditor): add keyboard shortcuts for tab navigation - Implemented 'Escape' key to move focus outside the editor for normal tab navigation. - Added 'Tab' key to insert a tab character and 'Shift-Tab' to decrease indentation, enhancing user experience with keyboard shortcuts. * feat(CodeEditor): integrate @codemirror/search module for find and replace binding * fix(ShortcutMenu): update focus disclaimer for Escape key usage - Changed the disclaimer text for the Escape key from "to change focus" to "to unfocus editor" for improved clarity. * feat(CodeEditor): add tests for added keyboard bindings * feat(CodeEditor): add ShortcutMenu story * refactor(CodeEditor): replace hardcoded selectors with constants for search panel and input - Updated test file to use `CodeEditorSelectors` for search panel and input field, improving maintainability and readability. * test(Panel): mock Modal component in tests to resolve HTMLDialogElement issues * style(Panel): remove commented-out flexbox styles from ModalStyles for cleaner code * style(ShortcutMenu): remove redundant margin-bottom style from table rows for cleaner CSS * style(Panel): define MODAL_HEIGHT constant for consistent height usage in ModalStyles * refactor(Panel): remove unused props from Tooltip for cleaner code * refactor(CodeEditor): replace ShortcutMenu with ShortcutTable - Updated CodeEditor stories and Panel component to utilize the new ShortcutTable component. - Removed the deprecated ShortcutMenu component and added styles and structure for the ShortcutTable. * fix(ShortcutTable): wrap table rows in tbody for proper HTML structure * feat(Panel): add menu state management to handle menu visibility - Introduced a new state for managing the menu's open/close status. - Updated the shortcuts modal handler to close the menu before opening the modal. - Passed the menu state and setter to the Menu component for better control. * fix(Panel): close menu on undo, redo, and download actions - Updated handleUndoClick, handleRedoClick, and handleDownloadClick to close the menu when these actions are triggered, improving user experience. * style(ShortcutTable): adjust row padding and heading margin for improved layout - Added a constant for row padding to standardize spacing between table rows. - Updated the margin of the heading to account for the new row padding, ensuring consistent visual alignment. * refactor(Panel): remove darkMode prop from Menu component for cleaner implementation
1 parent 702c8a0 commit bc61028

15 files changed

+632
-157
lines changed

packages/code-editor/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,11 @@
4242
"@codemirror/language": "^6.11.0",
4343
"@codemirror/legacy-modes": "^6.5.1",
4444
"@codemirror/lint": "^6.8.5",
45+
"@codemirror/search": "^6.5.11",
4546
"@codemirror/state": "^6.5.2",
4647
"@codemirror/view": "^6.38.1",
4748
"@leafygreen-ui/a11y": "workspace:^",
49+
"@leafygreen-ui/badge": "^10.1.0",
4850
"@leafygreen-ui/button": "workspace:^",
4951
"@leafygreen-ui/emotion": "workspace:^",
5052
"@leafygreen-ui/hooks": "workspace:^",
@@ -53,6 +55,7 @@
5355
"@leafygreen-ui/leafygreen-provider": "workspace:^",
5456
"@leafygreen-ui/lib": "workspace:^",
5557
"@leafygreen-ui/menu": "workspace:^",
58+
"@leafygreen-ui/modal": "^20.0.0",
5659
"@leafygreen-ui/palette": "workspace:^",
5760
"@leafygreen-ui/tokens": "workspace:^",
5861
"@leafygreen-ui/tooltip": "workspace:^",

packages/code-editor/src/CodeEditor.stories.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,12 @@ import { expect, waitFor } from '@storybook/test';
1010
import { css } from '@leafygreen-ui/emotion';
1111
// @ts-ignore LG icons don't currently support TS
1212
import CloudIcon from '@leafygreen-ui/icon/dist/Cloud';
13+
import Modal from '@leafygreen-ui/modal';
1314

1415
import { CopyButtonAppearance } from './CodeEditor/CodeEditor.types';
1516
import { LanguageName } from './CodeEditor/hooks/extensions/useLanguageExtension';
1617
import { IndentUnits } from './CodeEditor';
18+
import { ShortcutTable } from './ShortcutTable';
1719
import { codeSnippets } from './testing';
1820
import { CodeEditor, Panel } from '.';
1921

@@ -368,3 +370,13 @@ Typescript.args = {
368370
language: 'typescript',
369371
defaultValue: codeSnippets.typescript,
370372
};
373+
374+
export const ShortcutsMenu: StoryObj<{}> = {
375+
render: () => {
376+
return (
377+
<Modal open>
378+
<ShortcutTable />
379+
</Modal>
380+
);
381+
},
382+
};

packages/code-editor/src/CodeEditor/CodeEditor.spec.tsx

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { forceParsing } from '@codemirror/language';
22
import { EditorState } from '@codemirror/state';
33
import { act, waitFor } from '@testing-library/react';
4+
import userEvent from '@testing-library/user-event';
45

56
import { renderCodeEditor } from '../testing/testUtils';
67

@@ -669,4 +670,135 @@ describe('packages/code-editor', () => {
669670
}
670671
});
671672
});
673+
674+
describe('Keybindings', () => {
675+
test('Pressing ESC key unfocuses the editor', async () => {
676+
const { editor, container } = renderCodeEditor({
677+
defaultValue: 'console.log("test");',
678+
});
679+
680+
await editor.waitForEditorView();
681+
682+
// Focus the editor by clicking on the content area
683+
const contentElement = editor.getBySelector(CodeEditorSelectors.Content);
684+
userEvent.click(contentElement);
685+
686+
// Verify the editor is focused
687+
await waitFor(() => {
688+
expect(
689+
container.querySelector(CodeEditorSelectors.Focused),
690+
).toBeInTheDocument();
691+
});
692+
693+
// Press the ESC key
694+
userEvent.keyboard('{Escape}');
695+
696+
// Verify the editor is no longer focused
697+
await waitFor(() => {
698+
expect(
699+
container.querySelector(CodeEditorSelectors.Focused),
700+
).not.toBeInTheDocument();
701+
});
702+
});
703+
704+
test('Pressing CMD+F brings up the search menu', async () => {
705+
const { editor, container } = renderCodeEditor({
706+
defaultValue: 'console.log("hello world");\nconsole.log("test");',
707+
});
708+
709+
await editor.waitForEditorView();
710+
711+
// Focus the editor first
712+
const contentElement = editor.getBySelector(CodeEditorSelectors.Content);
713+
userEvent.click(contentElement);
714+
715+
// Verify editor is focused
716+
await waitFor(() => {
717+
expect(container.querySelector('.cm-focused')).toBeInTheDocument();
718+
});
719+
720+
// Press Ctrl+F to open search (works on most platforms)
721+
userEvent.keyboard('{Control>}f{/Control}');
722+
723+
// Check if the search panel appears
724+
await waitFor(() => {
725+
// CodeMirror 6 search creates a panel with specific classes
726+
const searchPanel = container.querySelector(
727+
CodeEditorSelectors.SearchPanel,
728+
);
729+
expect(searchPanel).toBeInTheDocument();
730+
});
731+
732+
// Verify search input field is present and can be typed in
733+
await waitFor(() => {
734+
const searchInput = container.querySelector(
735+
CodeEditorSelectors.SearchInput,
736+
);
737+
expect(searchInput).toBeInTheDocument();
738+
});
739+
});
740+
741+
test('Pressing TAB enters correct tab', async () => {
742+
const { editor } = renderCodeEditor({
743+
defaultValue: 'console.log("test");',
744+
indentUnit: 'tab',
745+
});
746+
747+
await editor.waitForEditorView();
748+
749+
// Focus the editor and position cursor at the start of the line
750+
const contentElement = editor.getBySelector(CodeEditorSelectors.Content);
751+
userEvent.click(contentElement);
752+
753+
// Position cursor at the beginning of the line
754+
userEvent.keyboard('{Home}');
755+
756+
// Get initial content
757+
const initialContent = editor.getContent();
758+
759+
// Press TAB
760+
userEvent.keyboard('{Tab}');
761+
762+
// Verify that indentation was inserted
763+
await waitFor(() => {
764+
const newContent = editor.getContent();
765+
// Should insert a tab character at the beginning
766+
expect(newContent).toBe('\tconsole.log("test");');
767+
expect(newContent).not.toBe(initialContent);
768+
expect(newContent.length).toBeGreaterThan(initialContent.length);
769+
});
770+
});
771+
772+
test('Pressing SHIFT+TAB lessens line indentation', async () => {
773+
const { editor } = renderCodeEditor({
774+
defaultValue: '\tconsole.log("test");', // Start with indented content
775+
indentUnit: 'tab',
776+
});
777+
778+
await editor.waitForEditorView();
779+
780+
// Focus the editor and position cursor on the indented line
781+
const contentElement = editor.getBySelector(CodeEditorSelectors.Content);
782+
userEvent.click(contentElement);
783+
784+
// Position cursor at the beginning of the line
785+
userEvent.keyboard('{Home}');
786+
787+
// Get initial content (should have tab indentation)
788+
const initialContent = editor.getContent();
789+
expect(initialContent).toBe('\tconsole.log("test");');
790+
791+
// Press SHIFT+TAB to reduce indentation
792+
userEvent.keyboard('{Shift>}{Tab}{/Shift}');
793+
794+
// Verify that indentation was reduced
795+
await waitFor(() => {
796+
const newContent = editor.getContent();
797+
// Should remove the tab indentation
798+
expect(newContent).toBe('console.log("test");');
799+
expect(newContent).not.toBe(initialContent);
800+
expect(newContent.length).toBeLessThan(initialContent.length);
801+
});
802+
});
803+
});
672804
});

packages/code-editor/src/CodeEditor/CodeEditor.tsx

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,9 +240,16 @@ export const CodeEditor = forwardRef<CodeEditorHandle, CodeEditorProps>(
240240
useLayoutEffect(() => {
241241
const EditorView = coreModules?.['@codemirror/view'];
242242
const commands = coreModules?.['@codemirror/commands'];
243+
const searchModule = coreModules?.['@codemirror/search'];
243244
const Prec = coreModules?.['@codemirror/state']?.Prec;
244245

245-
if (!editorContainerRef?.current || !EditorView || !Prec || !commands) {
246+
if (
247+
!editorContainerRef?.current ||
248+
!EditorView ||
249+
!Prec ||
250+
!commands ||
251+
!searchModule
252+
) {
246253
return;
247254
}
248255

@@ -255,6 +262,7 @@ export const CodeEditor = forwardRef<CodeEditorHandle, CodeEditorProps>(
255262
...consumerExtensions.map(extension => Prec.highest(extension)),
256263

257264
commands.history(),
265+
searchModule.search(),
258266

259267
EditorView.EditorView.updateListener.of((update: ViewUpdate) => {
260268
if (isControlled && update.docChanged) {
@@ -265,6 +273,23 @@ export const CodeEditor = forwardRef<CodeEditorHandle, CodeEditorProps>(
265273
}),
266274

267275
EditorView.keymap.of([
276+
{
277+
key: 'Escape',
278+
run: view => {
279+
// Move focus outside the editor to allow normal tab navigation
280+
view.contentDOM.blur();
281+
return true;
282+
},
283+
},
284+
{
285+
key: 'Tab',
286+
run: commands.insertTab,
287+
},
288+
{
289+
key: 'Shift-Tab',
290+
run: commands.indentLess,
291+
},
292+
...searchModule.searchKeymap,
268293
...commands.defaultKeymap,
269294
...commands.historyKeymap,
270295
]),

packages/code-editor/src/CodeEditor/CodeEditor.types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,9 @@ export const CodeEditorSelectors = {
6868
Line: '.cm-line',
6969
LineNumbers: '.cm-lineNumbers',
7070
LineWrapping: '.cm-lineWrapping',
71+
SearchInput:
72+
'input[type="text"], .cm-textfield, input[placeholder*="search" i]',
73+
SearchPanel: '.cm-search, .cm-panel',
7174
SelectionBackground: '.cm-selectionBackground',
7275
Tooltip: '.cm-tooltip',
7376
} as const;

packages/code-editor/src/CodeEditor/hooks/moduleLoaders.types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export interface CodeEditorModules {
2929
'@codemirror/state': typeof import('@codemirror/state');
3030
'@codemirror/commands': typeof import('@codemirror/commands');
3131
'@codemirror/autocomplete': typeof import('@codemirror/autocomplete');
32+
'@codemirror/search': typeof import('@codemirror/search');
3233

3334
// Prettier formatting modules
3435
'prettier/standalone': typeof import('prettier/standalone');

packages/code-editor/src/CodeEditor/hooks/useModuleLoaders.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const importCodeMirrorLanguage = () => import('@codemirror/language');
1515
const importCodeMirrorLint = () => import('@codemirror/lint');
1616
const importLezerHighlight = () => import('@lezer/highlight');
1717
const importCodeMirrorAutocomplete = () => import('@codemirror/autocomplete');
18+
const importCodeMirrorSearch = () => import('@codemirror/search');
1819
const importHyperLink = () => import('@uiw/codemirror-extensions-hyper-link');
1920

2021
// Language-specific imports
@@ -69,6 +70,7 @@ export const useModuleLoaders = ({
6970
'@codemirror/view': importCodeMirrorView,
7071
'@codemirror/state': importCodeMirrorState,
7172
'@codemirror/commands': importCodeMirrorCommands,
73+
'@codemirror/search': importCodeMirrorSearch,
7274
};
7375

7476
if (enableClickableUrls) {

packages/code-editor/src/Panel/Panel.spec.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,17 @@ import '@testing-library/jest-dom';
44

55
import { renderPanel } from '../testing/panelTestUtils';
66

7+
// Mock Modal component to avoid HTMLDialogElement issues
8+
jest.mock('@leafygreen-ui/modal', () => {
9+
return function MockModal({ children, open, ...props }: any) {
10+
return open ? (
11+
<div data-testid="mock-modal" {...props}>
12+
{children}
13+
</div>
14+
) : null;
15+
};
16+
});
17+
718
import { PanelProps } from './Panel.types';
819

920
const TestIcon = () => <div data-testid="test-icon" />;

packages/code-editor/src/Panel/Panel.styles.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
} from '@leafygreen-ui/tokens';
1010

1111
const PANEL_HEIGHT = 36;
12+
const MODAL_HEIGHT = 354;
1213

1314
export const getPanelStyles = (theme: Theme) => {
1415
return css`
@@ -51,3 +52,7 @@ export const getPanelButtonsStyles = () => {
5152
grid-area: buttons;
5253
`;
5354
};
55+
56+
export const ModalStyles = css`
57+
height: ${MODAL_HEIGHT}px;
58+
`;

0 commit comments

Comments
 (0)