diff --git a/.changeset/bumpy-turtles-start.md b/.changeset/bumpy-turtles-start.md new file mode 100644 index 0000000000..2935315268 --- /dev/null +++ b/.changeset/bumpy-turtles-start.md @@ -0,0 +1,24 @@ +--- +'@leafygreen-ui/code-editor': major +--- + +This release marks the V1.0.0 release of `@leafygreen-ui/code-editor`. + +Fixes remaining known of bugs. These include: + +- Adds gutter padding between the border and the caret when line numbers are turned off +- Fixes icon button vertical alignment in the panel +- Adds comprehensive story, that also include tooltips +- Increases tooltip speed +- Fixes collapsed chevron alignment +- Darkens unfold icon in dark mode +- Fixes widths. This includes making sure the panel and editor containers respect any set widths +- Corrects the color of the copied icon checkmark +- Disabled undo/redo when no undo/redo actions are available +- Hides blinking cursor when editor is in read only mode +- Allows for CTRL+click to pull up the context menu without losing the selection. +- Insert the cursor in the location of the undone action when the undo button is pressed in the panel menu +- Sets the loading height to be the same height as the future rendered editor +- Persists editor when props change +- Assures that font size and dark mode are respected in the panel +- Enhances tests diff --git a/packages/code-editor/src/CodeEditor.interactions.stories.tsx b/packages/code-editor/src/CodeEditor.interactions.stories.tsx index 935fde3bfa..458513ac01 100644 --- a/packages/code-editor/src/CodeEditor.interactions.stories.tsx +++ b/packages/code-editor/src/CodeEditor.interactions.stories.tsx @@ -16,6 +16,7 @@ import { CopyButtonAppearance, IndentUnits, } from './CodeEditor/CodeEditor.types'; +import { preLoadedModules } from './testing/preLoadedModules'; import { CodeEditor, LanguageName } from '.'; const meta: StoryMetaType = { @@ -196,6 +197,7 @@ export const ErrorTooltipOnHover: StoryObj<{}> = { return ( = { return ( = { return ( = { return ( = { render: () => { return ( {} }]} /> ); diff --git a/packages/code-editor/src/CodeEditor.stories.tsx b/packages/code-editor/src/CodeEditor.stories.tsx index 65180955f6..4d9364d44d 100644 --- a/packages/code-editor/src/CodeEditor.stories.tsx +++ b/packages/code-editor/src/CodeEditor.stories.tsx @@ -25,7 +25,10 @@ import LeafyGreenProvider from '@leafygreen-ui/leafygreen-provider'; import { Modal } from '@leafygreen-ui/modal'; import { BaseFontSize } from '@leafygreen-ui/tokens'; -import { CopyButtonAppearance } from './CodeEditor/CodeEditor.types'; +import { + CodeEditorTooltipSeverity, + CopyButtonAppearance, +} from './CodeEditor/CodeEditor.types'; import { LanguageName } from './CodeEditor/hooks/extensions/useLanguageExtension'; import { IndentUnits } from './CodeEditor'; import { ShortcutTable } from './ShortcutTable'; @@ -172,12 +175,64 @@ export default meta; const Template: StoryFn = args => ; export const LiveExample = Template.bind({}); - -export const WithPanel = Template.bind({}); const language = LanguageName.tsx; -WithPanel.args = { +LiveExample.args = { language, defaultValue: codeSnippets[language], + tooltips: [ + { + line: 4, + column: 11, + length: 11, + messages: ['This is an error tooltip'], + links: [ + { + label: 'External Link', + href: 'https://mongodb.com', + }, + ], + severity: CodeEditorTooltipSeverity.Error, + }, + { + line: 10, + column: 25, + length: 8, + messages: ['This is an info tooltip'], + links: [ + { + label: 'External Link', + href: 'https://mongodb.com', + }, + ], + severity: CodeEditorTooltipSeverity.Info, + }, + { + line: 12, + column: 14, + length: 7, + messages: ['This is an hint tooltip'], + links: [ + { + label: 'External Link', + href: 'https://mongodb.com', + }, + ], + severity: CodeEditorTooltipSeverity.Hint, + }, + { + line: 28, + column: 11, + length: 11, + messages: ['This is an warning tooltip'], + links: [ + { + label: 'External Link', + href: 'https://mongodb.com', + }, + ], + severity: CodeEditorTooltipSeverity.Warning, + }, + ], children: ( {}, + 'aria-label': 'Custom Button', + glyph: , + }, + ]} + title={`index.${language}`} + /> + ), +}; + /** * Syntax Highlighting Examples / Regressions * These have been hardcoded for now, but we could potentially determine a good diff --git a/packages/code-editor/src/CodeEditor/CodeEditor.ImperativeHandle.spec.tsx b/packages/code-editor/src/CodeEditor/CodeEditor.ImperativeHandle.spec.tsx new file mode 100644 index 0000000000..fdabfe7556 --- /dev/null +++ b/packages/code-editor/src/CodeEditor/CodeEditor.ImperativeHandle.spec.tsx @@ -0,0 +1,112 @@ +import { renderCodeEditor } from './CodeEditor.testUtils'; +import { LanguageName } from './hooks'; + +// Enhanced MutationObserver mock for CodeMirror compatibility +global.MutationObserver = jest.fn().mockImplementation(() => ({ + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn(), + takeRecords: jest.fn().mockReturnValue([]), +})); + +// Mock ResizeObserver which is used by CodeMirror +global.ResizeObserver = jest.fn().mockImplementation(() => ({ + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn(), +})); + +// Mock IntersectionObserver which may be used by CodeMirror +global.IntersectionObserver = jest.fn().mockImplementation(() => ({ + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn(), + root: null, + rootMargin: '', + thresholds: [], +})); + +describe('packages/code-editor/CodeEditor imperative handle', () => { + test('getContents works', () => { + const { editor } = renderCodeEditor({ + defaultValue: 'test', + }); + expect(editor.getHandle().getContents()).toBe('test'); + }); + + test('getEditorViewInstance works', () => { + const { editor } = renderCodeEditor({ + defaultValue: 'console.log("hello")', + }); + expect(editor.getHandle().getEditorViewInstance()).toBeDefined(); + }); + + test('undo and redo works', async () => { + const initialContent = 'console.log("hello");'; + const { editor } = renderCodeEditor({ + defaultValue: initialContent, + }); + const handle = editor.getHandle(); + expect(handle.getContents()).toBe(initialContent); + + // Make a change + editor.interactions.insertText('\nconsole.log("world");'); + const content = handle.getContents(); + expect(handle.getContents()).toBe( + 'console.log("hello");\nconsole.log("world");', + ); + + // Undo the change + const undoResult = handle.undo(); + expect(undoResult).toBe(true); + expect(handle.getContents()).toBe(initialContent); + + // Redo the change + const redoResult = editor.interactions.redo(); + expect(redoResult).toBe(true); + expect(handle.getContents()).toBe(content); + }); + + test('undo returns false when there is nothing to undo', async () => { + const { editor } = renderCodeEditor({ + defaultValue: 'test', + }); + const undoResult = editor.getHandle().undo(); + expect(undoResult).toBe(false); + }); + + test('redo returns false when there is nothing to redo', async () => { + const { editor } = renderCodeEditor({ + defaultValue: 'test', + }); + const redoResult = editor.getHandle().redo(); + expect(redoResult).toBe(false); + }); + + test('formatCode works', async () => { + const { editor } = renderCodeEditor({ + language: LanguageName.javascript, + defaultValue: "console.log('hello')", + }); + const handle = editor.getHandle(); + await handle.formatCode(); + expect(handle.getContents()).toBe("console.log('hello');\n"); + }); + + test('isFormattingAvailable returns true when formatting is available', () => { + const { editor } = renderCodeEditor({ + language: LanguageName.javascript, + defaultValue: "console.log('hello')", + }); + const handle = editor.getHandle(); + expect(handle.isFormattingAvailable).toBe(true); + }); + + test('isFormattingAvailable returns false when formatting is not available', () => { + const { editor } = renderCodeEditor({ + defaultValue: 'plain text', + }); + const handle = editor.getHandle(); + expect(handle.isFormattingAvailable).toBe(false); + }); +}); diff --git a/packages/code-editor/src/CodeEditor/CodeEditor.spec.tsx b/packages/code-editor/src/CodeEditor/CodeEditor.spec.tsx index 6072ae8fad..de081e822d 100644 --- a/packages/code-editor/src/CodeEditor/CodeEditor.spec.tsx +++ b/packages/code-editor/src/CodeEditor/CodeEditor.spec.tsx @@ -1,27 +1,21 @@ import React from 'react'; -import * as AutocompleteModule from '@codemirror/autocomplete'; -import * as CodeMirrorCommandsModule from '@codemirror/commands'; -import * as JavascriptModule from '@codemirror/lang-javascript'; -import { forceParsing } from '@codemirror/language'; -import * as LanguageModule from '@codemirror/language'; -import * as CodeMirrorSearchModule from '@codemirror/search'; import { EditorState } from '@codemirror/state'; -import * as CodeMirrorStateModule from '@codemirror/state'; -import * as CodeMirrorViewModule from '@codemirror/view'; -import * as LezerHighlightModule from '@lezer/highlight'; import { act, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import * as HyperLinkModule from '@uiw/codemirror-extensions-hyper-link'; -import * as CodeMirrorModule from 'codemirror'; -import * as ParserTypescriptModule from 'prettier/parser-typescript'; -import * as StandaloneModule from 'prettier/standalone'; -import { codeSnippets, getTestUtils } from '../testing'; +import { getTestUtils } from '../testing'; -import { LanguageName } from './hooks/extensions/useLanguageExtension'; +import { CodeEditor } from './CodeEditor'; import { renderCodeEditor } from './CodeEditor.testUtils'; -import { CopyButtonAppearance } from './CodeEditor.types'; -import { CodeEditor, CodeEditorSelectors } from '.'; +import { CodeEditorSelectors, CopyButtonAppearance } from './CodeEditor.types'; +import { LanguageName } from './hooks'; + +const mockForceParsing = jest.fn(); + +jest.mock('@codemirror/language', () => ({ + ...jest.requireActual('@codemirror/language'), + forceParsing: (...args: Array) => mockForceParsing(...args), +})); // Enhanced MutationObserver mock for CodeMirror compatibility global.MutationObserver = jest.fn().mockImplementation(() => ({ @@ -48,40 +42,6 @@ global.IntersectionObserver = jest.fn().mockImplementation(() => ({ thresholds: [], })); -// Mock document.getSelection for CodeMirror -if (!global.document.getSelection) { - global.document.getSelection = jest.fn().mockReturnValue({ - rangeCount: 0, - getRangeAt: jest.fn(), - removeAllRanges: jest.fn(), - addRange: jest.fn(), - toString: jest.fn().mockReturnValue(''), - }); -} - -// Mock createRange for CodeMirror -if (!global.document.createRange) { - global.document.createRange = jest.fn().mockReturnValue({ - setStart: jest.fn(), - setEnd: jest.fn(), - collapse: jest.fn(), - selectNodeContents: jest.fn(), - insertNode: jest.fn(), - surroundContents: jest.fn(), - cloneRange: jest.fn(), - detach: jest.fn(), - getClientRects: jest.fn().mockReturnValue([]), - getBoundingClientRect: jest.fn().mockReturnValue({ - top: 0, - left: 0, - bottom: 0, - right: 0, - width: 0, - height: 0, - }), - }); -} - // Mock getClientRects on Range prototype for CodeMirror search if (typeof Range !== 'undefined' && !Range.prototype.getClientRects) { Range.prototype.getClientRects = jest.fn().mockReturnValue([]); @@ -98,84 +58,45 @@ if (typeof Range !== 'undefined' && !Range.prototype.getClientRects) { }); } -// Mock console methods to suppress expected warnings -const originalConsoleWarn = console.warn; -const originalConsoleError = console.error; - -beforeAll(() => { - console.warn = jest.fn().mockImplementation((message: string) => { - // Suppress warnings about optional formatting modules not being installed - const suppressedWarnings = [ - '@wasm-fmt/clang-format is not installed', - '@wasm-fmt/gofmt is not installed', - '@wasm-fmt/ruff_fmt is not installed', - ]; - - if (!suppressedWarnings.includes(message)) { - originalConsoleWarn(message); - } +// Mock elementFromPoint which is used by CodeMirror for mouse position handling +if (!document.elementFromPoint) { + document.elementFromPoint = jest.fn(() => { + return document.body; }); +} - console.error = jest.fn().mockImplementation((message: string) => { - // Suppress React testing library deprecation warning - if ( - typeof message === 'string' && - message.includes('ReactDOMTestUtils.act') +describe('packages/code-editor/CodeEditor', () => { + beforeAll(() => { + // Mock HTMLDialogElement.show and HTMLDialogElement.close since they're not implemented in JSDOM + HTMLDialogElement.prototype.show = jest.fn(function mock( + this: HTMLDialogElement, ) { - return; - } - originalConsoleError(message); - }); - - HTMLDialogElement.prototype.show = jest.fn(function mock( - this: HTMLDialogElement, - ) { - this.open = true; + this.open = true; + }); + HTMLDialogElement.prototype.close = jest.fn(function mock( + this: HTMLDialogElement, + ) { + this.open = false; + }); }); - HTMLDialogElement.prototype.close = jest.fn(function mock( - this: HTMLDialogElement, - ) { - this.open = false; + beforeEach(() => { + mockForceParsing.mockClear(); }); -}); - -afterAll(() => { - console.warn = originalConsoleWarn; - console.error = originalConsoleError; -}); - -jest.mock('@codemirror/language', () => { - const actualModule = jest.requireActual('@codemirror/language'); - return { - ...actualModule, - forceParsing: jest.fn(), - }; -}); - -describe('packages/code-editor', () => { - test('Renders default value in editor', async () => { - const { editor, container } = renderCodeEditor({ - defaultValue: 'content', - }); - await editor.waitForEditorView(); - expect(container).toHaveTextContent('content'); + afterEach(() => { + jest.restoreAllMocks(); }); - test('Renders default value in editor with search disabled', async () => { - const { editor, container } = renderCodeEditor({ + test('renders', () => { + const { container } = renderCodeEditor({ defaultValue: 'content', }); - await editor.waitForEditorView(); - expect(container).toHaveTextContent('content'); }); - test('Updates value on when user types', async () => { + test('Updates value on when user types', () => { const { editor } = renderCodeEditor(); - await editor.waitForEditorView(); - expect( editor.getBySelector(CodeEditorSelectors.Content), ).not.toHaveTextContent('new content'); @@ -189,198 +110,153 @@ describe('packages/code-editor', () => { ); }); - test('Fold gutter renders when enabled', async () => { + test('Fold gutter renders when enabled', () => { const { editor } = renderCodeEditor({ enableCodeFolding: true }); - await editor.waitForEditorView(); - - await waitFor(() => { - expect( - editor.getBySelector(CodeEditorSelectors.FoldGutter), - ).toBeInTheDocument(); - }); + expect( + editor.getBySelector(CodeEditorSelectors.FoldGutter), + ).toBeInTheDocument(); }); - test('Fold gutter does not render when disabled', async () => { + test('Fold gutter does not render when disabled', () => { const { editor } = renderCodeEditor({ enableCodeFolding: false }); - await editor.waitForEditorView(); - expect( editor.queryBySelector(CodeEditorSelectors.FoldGutter), ).not.toBeInTheDocument(); }); - test('Line numbers render when enabled', async () => { + test('Line numbers render when enabled', () => { const { editor } = renderCodeEditor({ defaultValue: 'content', enableLineNumbers: true, }); - await editor.waitForEditorView(); - - await waitFor(() => { - expect( - editor.getBySelector(CodeEditorSelectors.GutterElement, { - text: '1', - }), - ).toBeInTheDocument(); - }); + expect( + editor.getBySelector(CodeEditorSelectors.GutterElement, { + text: '1', + }), + ).toBeInTheDocument(); }); - test('Line numbers do not render when disabled', async () => { + test('Line numbers do not render when disabled', () => { const { editor } = renderCodeEditor({ enableLineNumbers: false }); - await editor.waitForEditorView(); - - /** - * When the custom caret was used it appears the line number still gets - * rendered but is done so with visibility: hidden - */ expect( editor.queryBySelector(CodeEditorSelectors.LineNumbers), ).not.toBeInTheDocument(); }); - test('Clickable URLs render when enabled', async () => { + test('Clickable URLs render when enabled', () => { const { editor } = renderCodeEditor({ defaultValue: 'https://mongodb.design', enableClickableUrls: true, }); - await editor.waitForEditorView(); - - await waitFor(() => { - expect( - editor.getBySelector(CodeEditorSelectors.HyperLink), - ).toBeInTheDocument(); - }); + expect( + editor.getBySelector(CodeEditorSelectors.HyperLink), + ).toBeInTheDocument(); }); - test('Clickable URLs do not render when disable', async () => { + test('Clickable URLs do not render when disable', () => { const { editor } = renderCodeEditor({ defaultValue: 'https://mongodb.design', enableClickableUrls: false, }); - await editor.waitForEditorView(); - expect( editor.queryBySelector(CodeEditorSelectors.HyperLink), ).not.toBeInTheDocument(); }); - test('Read-only set on editor state when enabled', async () => { + test('Read-only set on editor state when enabled', () => { const { editor } = renderCodeEditor({ readOnly: true }); - await editor.waitForEditorView(); - - await waitFor(() => { - expect(editor.isReadOnly()).toBe(true); - }); + expect(editor.isReadOnly()).toBe(true); }); - test('Read-only not set on editor state when disabled', async () => { + test('Read-only not set on editor state when disabled', () => { const { editor } = renderCodeEditor({ readOnly: false }); - await editor.waitForEditorView(); - expect(editor.isReadOnly()).toBe(false); }); - test('Line wrapping enabled when enabled', async () => { + test('Line wrapping enabled when enabled', () => { const { editor } = renderCodeEditor({ enableLineWrapping: true }); - await editor.waitForEditorView(); - - await waitFor(() => { - expect(editor.isLineWrappingEnabled()).toBe(true); - }); + expect(editor.isLineWrappingEnabled()).toBe(true); }); - test('Line wrapping not enabled when disabled', async () => { + test('Line wrapping not enabled when disabled', () => { const { editor } = renderCodeEditor({ enableLineWrapping: false }); - await editor.waitForEditorView(); - expect(editor.isLineWrappingEnabled()).toBe(false); }); - test('Editor displays placeholder when empty', async () => { + test('Editor displays placeholder when empty', () => { const { editor } = renderCodeEditor({ placeholder: 'Type your code here...', }); - await editor.waitForEditorView(); - - await waitFor(() => { - expect( - editor.getBySelector(CodeEditorSelectors.Content), - ).toHaveTextContent('Type your code here...'); - }); + expect(editor.getBySelector(CodeEditorSelectors.Content)).toHaveTextContent( + 'Type your code here...', + ); }); - test('Editor displays HTMLElement placeholder when empty', async () => { + test('Editor displays HTMLElement placeholder when empty', () => { const placeholderElement = document.createElement('div'); placeholderElement.textContent = 'Type your code here...'; const { editor } = renderCodeEditor({ placeholder: placeholderElement }); - await editor.waitForEditorView(); - - await waitFor(() => { - expect( - editor.getBySelector(CodeEditorSelectors.Content), - ).toHaveTextContent('Type your code here...'); - }); + expect(editor.getBySelector(CodeEditorSelectors.Content)).toHaveTextContent( + 'Type your code here...', + ); }); test('the forceParsing() method is called when enabled', async () => { - const { editor } = renderCodeEditor({ + renderCodeEditor({ forceParsing: true, defaultValue: 'content', }); - await editor.waitForEditorView(); + await waitFor(() => { + expect(mockForceParsing).toHaveBeenCalled(); + }); + }); - expect(forceParsing as jest.Mock).toHaveBeenCalled(); + test('the forceParsing() method is not called when disabled', async () => { + const { container } = renderCodeEditor({ + forceParsing: false, + defaultValue: 'content', + }); + // Wait for the editor to be fully rendered + await waitFor(() => { + expect(container).toHaveTextContent('content'); + }); + expect(mockForceParsing).not.toHaveBeenCalled(); }); - test('correct indentUnit is set on the editor when indentUnit is "space" and indentSize is 2', async () => { + test('correct indentUnit is set on the editor when indentUnit is "tab"', () => { const { editor } = renderCodeEditor({ - indentUnit: 'space', - indentSize: 2, + indentUnit: 'tab', }); - await editor.waitForEditorView(); - - expect(editor.getIndentUnit()).toBe(' '); + expect(editor.getIndentUnit()).toBe('\t'); }); - test('correct indentUnit is set on the editor when indentUnit is "space" and indentSize is 4', async () => { + test('correct indentUnit is set on the editor when indentUnit is "space"', () => { const { editor } = renderCodeEditor({ indentUnit: 'space', - indentSize: 4, - }); - await editor.waitForEditorView(); - - await waitFor(() => { - expect(editor.getIndentUnit()).toBe(' '); }); + expect(editor.getIndentUnit()).toBe(' '); }); - test('correct indentUnit is set on the editor when indentUnit is "tab"', async () => { + test('correct indentUnit is set on the editor when indentUnit is "space" and indentSize is 4', () => { const { editor } = renderCodeEditor({ - indentUnit: 'tab', - }); - await editor.waitForEditorView(); - - await waitFor(() => { - expect(editor.getIndentUnit()).toBe('\t'); + indentUnit: 'space', + indentSize: 4, }); + expect(editor.getIndentUnit()).toBe(' '); }); - test('applies custom extensions to the editor', async () => { + test('applies custom extensions to the editor', () => { const { editor } = renderCodeEditor({ extensions: [EditorState.readOnly.of(true)], }); - await editor.waitForEditorView(); - expect(editor.isReadOnly()).toBe(true); }); - test('custom extensions have precendence over built-in functionality', async () => { + test('custom extensions have precedence over built-in functionality', () => { const { editor } = renderCodeEditor({ readOnly: false, extensions: [EditorState.readOnly.of(true)], }); - await editor.waitForEditorView(); - expect(editor.isReadOnly()).toBe(true); }); @@ -395,940 +271,163 @@ describe('packages/code-editor', () => { lang !== LanguageName.tsx && lang !== LanguageName.jsx, ), - )('adds language support for %p', async language => { + )('adds language support for %p', language => { const { container } = renderCodeEditor({ language }); - await waitFor(() => { - expect( - container.querySelector(`[data-language="${language}"]`), - ).toBeInTheDocument(); - }); + expect( + container.querySelector(`[data-language="${language}"]`), + ).toBeInTheDocument(); }); - test('adds language support for tsx', async () => { + test('adds language support for tsx', () => { const { container } = renderCodeEditor({ language: LanguageName.tsx }); - await waitFor(() => { - expect( - container.querySelector(`[data-language="typescript"]`), - ).toBeInTheDocument(); - }); + expect( + container.querySelector(`[data-language="typescript"]`), + ).toBeInTheDocument(); }); - test('adds language support for jsx', async () => { + test('adds language support for jsx', () => { const { container } = renderCodeEditor({ language: LanguageName.jsx }); - await waitFor(() => { - expect( - container.querySelector(`[data-language="javascript"]`), - ).toBeInTheDocument(); - }); + expect( + container.querySelector(`[data-language="javascript"]`), + ).toBeInTheDocument(); }); - test('renders copy button when copyButtonAppearance is "hover"', async () => { + test('renders copy button when copyButtonAppearance is "hover"', () => { const lgId = 'lg-test-copy-hover'; - const { editor } = renderCodeEditor({ + renderCodeEditor({ copyButtonAppearance: CopyButtonAppearance.Hover, 'data-lgid': lgId, }); - await editor.waitForEditorView(); const utils = getTestUtils(lgId); expect(utils.getCopyButton()).toBeInTheDocument(); }); - test('renders copy button when copyButtonAppearance is "persist"', async () => { + test('renders copy button when copyButtonAppearance is "persist"', () => { const lgId = 'lg-test-copy-persist'; - const { editor } = renderCodeEditor({ + renderCodeEditor({ copyButtonAppearance: CopyButtonAppearance.Persist, 'data-lgid': lgId, }); - await editor.waitForEditorView(); const utils = getTestUtils(lgId); expect(utils.getCopyButton()).toBeInTheDocument(); }); - test('does not render copy button when copyButtonAppearance is "none"', async () => { - const { container, editor } = renderCodeEditor({ + test('does not render copy button when copyButtonAppearance is "none"', () => { + const { container } = renderCodeEditor({ copyButtonAppearance: CopyButtonAppearance.None, }); - - await editor.waitForEditorView(); - expect( container.querySelector(CodeEditorSelectors.CopyButton), ).not.toBeInTheDocument(); }); - describe('imperative handle', () => { - test('exposes complete imperative handle API', async () => { - const { editor } = renderCodeEditor(); - - await editor.waitForEditorView(); - - const handle = editor.getHandle(); - expect(typeof handle.undo).toBe('function'); - expect(typeof handle.redo).toBe('function'); - expect(typeof handle.getContents).toBe('function'); - expect(typeof handle.formatCode).toBe('function'); - expect(typeof handle.getEditorViewInstance).toBe('function'); - }); - - test('undo and redo actually work with content changes', async () => { - const initialContent = 'console.log("hello");'; - const { editor } = renderCodeEditor({ - defaultValue: initialContent, - }); - - await editor.waitForEditorView(); - - // Verify initial content - expect(editor.getContent()).toBe(initialContent); - - // Make a change - editor.interactions.insertText('\nconsole.log("world");'); - const changedContent = editor.getContent(); - expect(changedContent).toBe( - 'console.log("hello");\nconsole.log("world");', - ); - - // Undo the change - const undoResult = editor.interactions.undo(); - expect(undoResult).toBe(true); - expect(editor.getContent()).toBe(initialContent); - - // Redo the change - const redoResult = editor.interactions.redo(); - expect(redoResult).toBe(true); - expect(editor.getContent()).toBe(changedContent); - }); - - test('undo returns false when there is nothing to undo', async () => { - const { editor } = renderCodeEditor({ - defaultValue: 'test', - }); - - await editor.waitForEditorView(); - - // Try to undo when there's no history - const undoResult = editor.interactions.undo(); - expect(undoResult).toBe(false); - }); - - test('redo returns false when there is nothing to redo', async () => { - const { editor } = renderCodeEditor({ - defaultValue: 'test', - }); - - await editor.waitForEditorView(); - - // Try to redo when there's no redo history - const redoResult = editor.interactions.redo(); - expect(redoResult).toBe(false); + test('renders panel when panel is passed as a child', () => { + const lgId = 'lg-test-editor'; + renderCodeEditor({ + 'data-lgid': lgId, + children: , }); + const utils = getTestUtils(lgId).getPanelUtils(); + const panelElement = utils.getPanelElement(); + expect(panelElement).toBeInTheDocument(); }); - describe('Download functionality', () => { - let downloadedFiles: Array<{ - filename: string; - content: string; - type: string; - }> = []; - - beforeEach(() => { - downloadedFiles = []; - - // Mock the download behavior by intercepting anchor clicks - const originalCreateElement = document.createElement; - jest.spyOn(document, 'createElement').mockImplementation(tagName => { - if (tagName === 'a') { - const anchor = originalCreateElement.call( - document, - 'a', - ) as HTMLAnchorElement; - - // Override the click method to capture download attempts - anchor.click = function () { - if (this.download && this.href.startsWith('blob:')) { - // Extract content from the blob URL (in a real test, this would be more complex) - // For our test purposes, we'll capture the download details - downloadedFiles.push({ - filename: this.download, - content: 'captured-from-blob', // In reality, we'd need to read the blob - type: 'text/plain', - }); - } - // Don't call the original click to avoid actual download attempts - }; - - return anchor; - } - - return originalCreateElement.call(document, tagName); - }); - - // Mock URL.createObjectURL to return a predictable value - if (!global.URL) global.URL = {} as any; - global.URL.createObjectURL = jest.fn(() => 'blob:mock-url'); - global.URL.revokeObjectURL = jest.fn(); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - test('uses provided filename exactly as-is when custom filename provided', async () => { - try { - downloadedFiles = []; - - const { editor } = renderCodeEditor({ - defaultValue: 'console.log("Hello World");', - language: LanguageName.javascript, - }); - await editor.waitForEditorView(); - - const handle = editor.getHandle(); - - act(() => { - handle.downloadContent('my-script'); - }); - - // Verify that a download was triggered - expect(downloadedFiles).toHaveLength(1); - expect(downloadedFiles[0].filename).toBe('my-script'); - - // Verify URL methods were called (cleanup) - expect(global.URL.createObjectURL).toHaveBeenCalled(); - expect(global.URL.revokeObjectURL).toHaveBeenCalledWith( - 'blob:mock-url', - ); - } catch (error) { - // If the full editor test fails due to environment issues, - // skip this test for now - the important download logic is tested in integration - console.warn( - 'Skipping CodeEditor download test due to environment issues:', - error, - ); - } - }); - - test('triggers download with default filename when none provided', async () => { - try { - downloadedFiles = []; - - const { editor } = renderCodeEditor({ - defaultValue: 'print("Hello World")', - language: LanguageName.python, - }); - await editor.waitForEditorView(); - - const handle = editor.getHandle(); - - act(() => { - handle.downloadContent(); - }); - - expect(downloadedFiles).toHaveLength(1); - expect(downloadedFiles[0].filename).toBe('code.py'); - } catch (error) { - console.warn('Skipping test due to environment issues:', error); - } - }); - - test('does not trigger download when content is empty', async () => { - try { - downloadedFiles = []; - - const { editor } = renderCodeEditor({ - defaultValue: '', - }); - await editor.waitForEditorView(); - - const handle = editor.getHandle(); - - act(() => { - handle.downloadContent(); - }); - - // No download should be triggered for empty content - expect(downloadedFiles).toHaveLength(0); - expect(console.warn).toHaveBeenCalledWith( - 'Cannot download empty content', - ); - } catch (error) { - console.warn('Skipping test due to environment issues:', error); - } - }); - - test('does not trigger download when content is only whitespace', async () => { - try { - downloadedFiles = []; - - const { editor } = renderCodeEditor({ - defaultValue: ' \n\t ', - }); - await editor.waitForEditorView(); - - const handle = editor.getHandle(); - - act(() => { - handle.downloadContent(); - }); - - expect(downloadedFiles).toHaveLength(0); - expect(console.warn).toHaveBeenCalledWith( - 'Cannot download empty content', - ); - } catch (error) { - console.warn('Skipping test due to environment issues:', error); - } + test('does not render context menu when right-clicking on panel', () => { + const lgId = 'lg-test-editor'; + renderCodeEditor({ + 'data-lgid': lgId, + children: , }); + const utils = getTestUtils(lgId).getPanelUtils(); + const panelElement = utils.getPanelElement(); + expect(panelElement).toBeInTheDocument(); + userEvent.click(panelElement!, { button: 2 }); + expect(utils.querySecondaryMenu()).not.toBeInTheDocument(); + }); - test.each([ - [LanguageName.javascript, 'js'], - [LanguageName.typescript, 'ts'], - [LanguageName.tsx, 'tsx'], - [LanguageName.jsx, 'jsx'], - [LanguageName.python, 'py'], - [LanguageName.java, 'java'], - [LanguageName.css, 'css'], - [LanguageName.html, 'html'], - [LanguageName.json, 'json'], - [LanguageName.go, 'go'], - [LanguageName.rust, 'rs'], - [LanguageName.cpp, 'cpp'], - [LanguageName.csharp, 'cs'], - [LanguageName.kotlin, 'kt'], - [LanguageName.php, 'php'], - [LanguageName.ruby, 'rb'], - ])( - 'adds correct extension for %s language when using default filename', - async (language, expectedExtension) => { - try { - // Reset for each test iteration - downloadedFiles = []; - - const { editor } = renderCodeEditor({ - defaultValue: 'test content', - language, - }); - await editor.waitForEditorView(); - - const handle = editor.getHandle(); - - act(() => { - handle.downloadContent(); // No filename provided, uses default - }); - - expect(downloadedFiles).toHaveLength(1); - expect(downloadedFiles[0].filename).toBe(`code.${expectedExtension}`); - } catch (error) { - console.warn( - `Skipping ${language} test due to environment issues:`, - error, - ); - } - }, - ); - - test('adds txt extension for unsupported language when using default filename', async () => { - try { - downloadedFiles = []; - - const { editor } = renderCodeEditor({ - defaultValue: 'some content', - // No language specified - }); - await editor.waitForEditorView(); - - const handle = editor.getHandle(); - - act(() => { - handle.downloadContent(); // No filename provided, uses default - }); - - expect(downloadedFiles).toHaveLength(1); - expect(downloadedFiles[0].filename).toBe('code.txt'); - } catch (error) { - console.warn('Skipping test due to environment issues:', error); - } + test('Pressing ESC key unfocuses the editor', async () => { + const { editor, container } = renderCodeEditor({ + defaultValue: 'console.log("test");', }); - test('uses provided filename exactly as-is regardless of extension', async () => { - try { - downloadedFiles = []; - - const { editor } = renderCodeEditor({ - defaultValue: 'console.log("Hello World");', - language: LanguageName.javascript, - }); - await editor.waitForEditorView(); + // Focus the editor by clicking on the content area + const contentElement = editor.getBySelector(CodeEditorSelectors.Content); + userEvent.click(contentElement); - const handle = editor.getHandle(); - - act(() => { - handle.downloadContent('my-script.txt'); - }); - - expect(downloadedFiles).toHaveLength(1); - expect(downloadedFiles[0].filename).toBe('my-script.txt'); - } catch (error) { - console.warn('Skipping test due to environment issues:', error); - } + // Verify the editor is focused + await waitFor(() => { + expect( + container.querySelector(CodeEditorSelectors.Focused), + ).toBeInTheDocument(); }); - }); - - describe('Panel', () => { - test('does not render context menu when right-clicking on panel', async () => { - const PANEL_TEST_ID = 'test-panel'; - - const { editor, container } = renderCodeEditor( - { 'data-lgid': 'lg-test-editor' }, - { - children: ( - Test Panel Content - } - /> - ), - }, - ); - - await editor.waitForEditorView(); - const panelElement = container.querySelector( - `[data-testid="${PANEL_TEST_ID}"]`, - ); - expect(panelElement).toBeInTheDocument(); - - // Right-click on the panel to trigger context menu - userEvent.click(panelElement!, { button: 2 }); + // Press the ESC key + userEvent.keyboard('{Escape}'); + // Verify the editor is no longer focused + await waitFor(() => { expect( - container.querySelector('[data-lgid="lg-test-editor-context_menu"]'), + container.querySelector(CodeEditorSelectors.Focused), ).not.toBeInTheDocument(); }); }); - describe('Keybindings', () => { - test('Pressing ESC key unfocuses the editor', async () => { - const { editor, container } = renderCodeEditor({ - defaultValue: 'console.log("test");', - }); - - await editor.waitForEditorView(); - - // Focus the editor by clicking on the content area - const contentElement = editor.getBySelector(CodeEditorSelectors.Content); - userEvent.click(contentElement); - - // Verify the editor is focused - await waitFor(() => { - expect( - container.querySelector(CodeEditorSelectors.Focused), - ).toBeInTheDocument(); - }); - - // Press the ESC key - userEvent.keyboard('{Escape}'); - - // Verify the editor is no longer focused - await waitFor(() => { - expect( - container.querySelector(CodeEditorSelectors.Focused), - ).not.toBeInTheDocument(); - }); - }); - - test('Pressing TAB enters correct tab', async () => { - const { editor } = renderCodeEditor({ - defaultValue: 'console.log("test");', - indentUnit: 'tab', - }); - - await editor.waitForEditorView(); - - // Focus the editor and position cursor at the start of the line - const contentElement = editor.getBySelector(CodeEditorSelectors.Content); - userEvent.click(contentElement); - - // Position cursor at the beginning of the line - userEvent.keyboard('{Home}'); - - // Get initial content - const initialContent = editor.getContent(); - - // Press TAB - userEvent.keyboard('{Tab}'); - - // Verify that indentation was inserted - await waitFor(() => { - const newContent = editor.getContent(); - // Should insert a tab character at the beginning - expect(newContent).toBe('\tconsole.log("test");'); - expect(newContent).not.toBe(initialContent); - expect(newContent.length).toBeGreaterThan(initialContent.length); - }); + test('Pressing TAB enters correct tab', async () => { + const { editor } = renderCodeEditor({ + defaultValue: 'console.log("test");', + indentUnit: 'tab', }); - test('Pressing SHIFT+TAB lessens line indentation', async () => { - const { editor } = renderCodeEditor({ - defaultValue: '\tconsole.log("test");', // Start with indented content - indentUnit: 'tab', - }); - - await editor.waitForEditorView(); - - // Focus the editor and position cursor on the indented line - const contentElement = editor.getBySelector(CodeEditorSelectors.Content); - userEvent.click(contentElement); + // Focus the editor and position cursor at the start of the line + const contentElement = editor.getBySelector(CodeEditorSelectors.Content); + userEvent.click(contentElement); - // Position cursor at the beginning of the line - userEvent.keyboard('{Home}'); + // Position cursor at the beginning of the line + userEvent.keyboard('{Home}'); - // Get initial content (should have tab indentation) - const initialContent = editor.getContent(); - expect(initialContent).toBe('\tconsole.log("test");'); - - // Press SHIFT+TAB to reduce indentation - userEvent.keyboard('{Shift>}{Tab}{/Shift}'); - - // Verify that indentation was reduced - await waitFor(() => { - const newContent = editor.getContent(); - // Should remove the tab indentation - expect(newContent).toBe('console.log("test");'); - expect(newContent).not.toBe(initialContent); - expect(newContent.length).toBeLessThan(initialContent.length); - }); - }); - }); + // Get initial content + const initialContent = editor.getContent(); - describe('Pre Loaded Modules', () => { - test('editor is rendered immediately when pre loaded modules are provided', async () => { - const { editor } = renderCodeEditor({ - language: LanguageName.typescript, - defaultValue: codeSnippets.typescript, - preLoadedModules: { - codemirror: CodeMirrorModule, - '@codemirror/view': CodeMirrorViewModule, - '@codemirror/state': CodeMirrorStateModule, - '@codemirror/commands': CodeMirrorCommandsModule, - '@codemirror/search': CodeMirrorSearchModule, - '@uiw/codemirror-extensions-hyper-link': HyperLinkModule, - '@codemirror/language': LanguageModule, - '@lezer/highlight': LezerHighlightModule, - '@codemirror/autocomplete': AutocompleteModule, - '@codemirror/lang-javascript': JavascriptModule, - 'prettier/standalone': StandaloneModule, - 'prettier/parser-typescript': ParserTypescriptModule, - }, - }); + // Press TAB + userEvent.keyboard('{Tab}'); - expect( - editor.getBySelector(CodeEditorSelectors.Content), - ).toBeInTheDocument(); + // Verify that indentation was inserted + await waitFor(() => { + const newContent = editor.getContent(); + // Should insert a tab character at the beginning + expect(newContent).toBe('\tconsole.log("test");'); + expect(newContent).not.toBe(initialContent); + expect(newContent.length).toBeGreaterThan(initialContent.length); }); }); - describe('SearchPanel', () => { - // PreLoad modules to avoid lazy loading issues in tests - const testModules = { - codemirror: CodeMirrorModule, - '@codemirror/view': CodeMirrorViewModule, - '@codemirror/state': CodeMirrorStateModule, - '@codemirror/commands': CodeMirrorCommandsModule, - '@codemirror/search': CodeMirrorSearchModule, - '@uiw/codemirror-extensions-hyper-link': HyperLinkModule, - '@codemirror/language': LanguageModule, - '@lezer/highlight': LezerHighlightModule, - '@codemirror/autocomplete': AutocompleteModule, - '@codemirror/lang-javascript': JavascriptModule, - 'prettier/standalone': StandaloneModule, - 'prettier/parser-typescript': ParserTypescriptModule, - }; - - /** - * Helper function to render the editor and open the search panel - */ - async function renderEditorAndOpenSearchPanel(defaultValue: string) { - const { editor, container } = renderCodeEditor({ - defaultValue, - preLoadedModules: testModules, - }); - - await editor.waitForEditorView(); - - // Focus the editor - const contentElement = editor.getBySelector(CodeEditorSelectors.Content); - await userEvent.click(contentElement); - - // Wait for editor to be focused - await waitFor(() => { - expect(container.querySelector('.cm-focused')).toBeInTheDocument(); - }); - - // Press Ctrl+F to open search - await userEvent.keyboard('{Control>}f{/Control}'); - - // Wait for search panel to appear - await waitFor(() => { - expect( - container.querySelector('input[placeholder="Find"]'), - ).toBeInTheDocument(); - }); - - return { editor, container }; - } - - test('Pressing CMD+F pulls up the search panel', async () => { - await renderEditorAndOpenSearchPanel('console.log("hello");'); - }); - - test('Pressing ESC closes the search panel', async () => { - const { container } = await renderEditorAndOpenSearchPanel( - 'console.log("hello");', - ); - - // Get the search input and focus it - const searchInput = container.querySelector( - 'input[placeholder="Find"]', - ) as HTMLInputElement; - expect(searchInput).toBeInTheDocument(); - - // Press ESC to close - await userEvent.click(searchInput); - await userEvent.keyboard('{Escape}'); - - // Verify search panel is closed - await waitFor(() => { - expect( - container.querySelector(CodeEditorSelectors.SearchPanel), - ).not.toBeInTheDocument(); - }); - }); - - test('Clicking the close button closes the search panel', async () => { - const { container } = await renderEditorAndOpenSearchPanel( - 'console.log("hello");', - ); - - // Find and click the close button (X icon button) - const closeButton = container.querySelector( - 'button[aria-label="close find menu button"]', - ) as HTMLButtonElement; - expect(closeButton).toBeInTheDocument(); - - await userEvent.click(closeButton); - - // Verify search panel is closed - await waitFor(() => { - expect( - container.querySelector(CodeEditorSelectors.SearchPanel), - ).not.toBeInTheDocument(); - }); - }); - - test('Clicking The ChevronDown expands the panel to show the replace panel', async () => { - const { container } = await renderEditorAndOpenSearchPanel( - 'console.log("hello");', - ); - - // Initially, replace section should be hidden (aria-hidden) - const replaceSection = container.querySelector('[aria-hidden="true"]'); - expect(replaceSection).toBeInTheDocument(); - - // Click the toggle button (ChevronDown) - const toggleButton = container.querySelector( - 'button[aria-label="Toggle button"]', - ) as HTMLButtonElement; - expect(toggleButton).toBeInTheDocument(); - expect(toggleButton.getAttribute('aria-expanded')).toBe('false'); - - await userEvent.click(toggleButton); - - // Verify the toggle button is expanded - await waitFor(() => { - expect(toggleButton.getAttribute('aria-expanded')).toBe('true'); - }); - - // Verify replace input is now accessible - const replaceInput = container.querySelector( - 'input[placeholder="Replace"]', - ) as HTMLInputElement; - expect(replaceInput).toBeInTheDocument(); - }); - - test('Pressing Enter after typing in the search input focuses the next match', async () => { - const { container } = await renderEditorAndOpenSearchPanel( - 'hello\nhello\nhello', - ); - - // Type in the search input - const searchInput = container.querySelector( - 'input[placeholder="Find"]', - ) as HTMLInputElement; - await userEvent.click(searchInput); - await userEvent.type(searchInput, 'hello'); - - // Wait for matches to be found - await waitFor(() => { - expect(searchInput.value).toBe('hello'); - expect(container.textContent).toContain('/3'); - }); - - // Press Enter to go to next match - await userEvent.keyboard('{Enter}'); - - // Verify that the selection moved (check for match count update and selected text) - await waitFor(() => { - // After pressing Enter, should move to first match - expect(container.textContent).toContain('1/3'); - - const selectedMatch = container.querySelector( - '.cm-searchMatch-selected', - ); - expect(selectedMatch).toBeInTheDocument(); - expect(selectedMatch?.innerHTML).toBe('hello'); - }); - }); - - test('Clicking the arrow down button focuses the next match', async () => { - const { container } = await renderEditorAndOpenSearchPanel( - 'test\ntest\ntest', - ); - - // Type in the search input - const searchInput = container.querySelector( - 'input[placeholder="Find"]', - ) as HTMLInputElement; - await userEvent.click(searchInput); - await userEvent.type(searchInput, 'test'); - - // Wait for matches to be found - await waitFor(() => { - expect(searchInput.value).toBe('test'); - expect(container.textContent).toContain('/3'); - }); - - // Click the arrow down button - const arrowDownButton = container.querySelector( - 'button[aria-label="next item button"]', - ) as HTMLButtonElement; - expect(arrowDownButton).toBeInTheDocument(); - - await userEvent.click(arrowDownButton); - - // Verify that the selection moved to the first match - await waitFor(() => { - expect(container.textContent).toContain('1/3'); - const selectedMatch = container.querySelector( - '.cm-searchMatch-selected', - ); - expect(selectedMatch).toBeInTheDocument(); - expect(selectedMatch?.innerHTML).toBe('test'); - }); - }); - - test('Pressing Shift+Enter after typing in the search input focuses the previous match', async () => { - const { container } = await renderEditorAndOpenSearchPanel( - 'hello\nhello\nhello', - ); - - // Type in the search input - const searchInput = container.querySelector( - 'input[placeholder="Find"]', - ) as HTMLInputElement; - await userEvent.click(searchInput); - await userEvent.type(searchInput, 'hello'); - - // Wait for matches to be found - await waitFor(() => { - expect(searchInput.value).toBe('hello'); - expect(container.textContent).toContain('/3'); - }); - - // Press Enter to go to first match - await userEvent.keyboard('{Enter}'); - - await waitFor(() => { - expect(container.textContent).toContain('1/3'); - }); - - // Press Shift+Enter to go to previous match (should wrap to last) - await userEvent.keyboard('{Shift>}{Enter}{/Shift}'); - - // Verify that the selection moved backwards - await waitFor(() => { - // Should wrap to last match (3) - expect(container.textContent).toContain('3/3'); - }); - }); - - test('Clicking the arrow up button focuses the previous match', async () => { - const { container } = await renderEditorAndOpenSearchPanel( - 'test\ntest\ntest', - ); - - // Type in the search input - const searchInput = container.querySelector( - 'input[placeholder="Find"]', - ) as HTMLInputElement; - await userEvent.click(searchInput); - await userEvent.type(searchInput, 'test'); - - // Wait for matches - await waitFor(() => { - expect(searchInput.value).toBe('test'); - expect(container.textContent).toContain('/3'); - }); - - // Click the arrow up button (should wrap to last match) - const arrowUpButton = container.querySelector( - 'button[aria-label="previous item button"]', - ) as HTMLButtonElement; - expect(arrowUpButton).toBeInTheDocument(); - - await userEvent.click(arrowUpButton); - - // Verify that the selection moved (should wrap to last match) - await waitFor(() => { - expect(container.textContent).toContain('3/3'); - }); - }); - - test('Clicking the replace button replaces the next match', async () => { - const { editor, container } = await renderEditorAndOpenSearchPanel( - 'hello world\nhello again', - ); - - // Expand to show replace panel - const toggleButton = container.querySelector( - 'button[aria-label="Toggle button"]', - ) as HTMLButtonElement; - await userEvent.click(toggleButton); - - await waitFor(() => { - expect(toggleButton.getAttribute('aria-expanded')).toBe('true'); - }); - - // Type search term - const searchInput = container.querySelector( - 'input[placeholder="Find"]', - ) as HTMLInputElement; - await userEvent.click(searchInput); - await userEvent.type(searchInput, 'hello'); - - // Wait for matches - await waitFor(() => { - expect(container.textContent).toContain('/2'); - }); - - // Type replace term - const replaceInput = container.querySelector( - 'input[placeholder="Replace"]', - ) as HTMLInputElement; - await userEvent.click(replaceInput); - await userEvent.type(replaceInput, 'goodbye'); - - // Find first match - const arrowDownButton = container.querySelector( - 'button[aria-label="next item button"]', - ) as HTMLButtonElement; - await userEvent.click(arrowDownButton); - - await waitFor(() => { - expect(container.textContent).toContain('1/2'); - }); - - // Click replace button - const replaceButton = container.querySelector( - 'button[aria-label="replace button"]', - ) as HTMLButtonElement; - expect(replaceButton).toBeInTheDocument(); - - await userEvent.click(replaceButton); - - // Verify that one match was replaced - await waitFor(() => { - const content = editor.getContent(); - expect(content).toContain('goodbye world'); - expect(content).toContain('hello again'); - // Should now only have 1 match left - expect(container.textContent).toContain('/1'); - }); + test('Pressing SHIFT+TAB lessens line indentation', async () => { + const { editor } = renderCodeEditor({ + defaultValue: '\tconsole.log("test");', // Start with indented content + indentUnit: 'tab', }); - test('Clicking the replace all button replaces all matches', async () => { - const { editor, container } = await renderEditorAndOpenSearchPanel( - 'hello world\nhello again\nhello there', - ); - - // Expand to show replace panel - const toggleButton = container.querySelector( - 'button[aria-label="Toggle button"]', - ) as HTMLButtonElement; - await userEvent.click(toggleButton); + // Focus the editor and position cursor on the indented line + const contentElement = editor.getBySelector(CodeEditorSelectors.Content); + userEvent.click(contentElement); - await waitFor(() => { - expect(toggleButton.getAttribute('aria-expanded')).toBe('true'); - }); + // Position cursor at the beginning of the line + userEvent.keyboard('{Home}'); - // Type search term - const searchInput = container.querySelector( - 'input[placeholder="Find"]', - ) as HTMLInputElement; - await userEvent.click(searchInput); - await userEvent.type(searchInput, 'hello'); + // Get initial content (should have tab indentation) + const initialContent = editor.getContent(); + expect(initialContent).toBe('\tconsole.log("test");'); - // Wait for matches - await waitFor(() => { - expect(container.textContent).toContain('/3'); - }); + // Press SHIFT+TAB to reduce indentation + userEvent.keyboard('{Shift>}{Tab}{/Shift}'); - // Type replace term - const replaceInput = container.querySelector( - 'input[placeholder="Replace"]', - ) as HTMLInputElement; - await userEvent.click(replaceInput); - await userEvent.type(replaceInput, 'goodbye'); - - // Click replace all button - const replaceAllButton = container.querySelector( - 'button[aria-label="replace all button"]', - ) as HTMLButtonElement; - expect(replaceAllButton).toBeInTheDocument(); - - await userEvent.click(replaceAllButton); - - // Verify that all matches were replaced - await waitFor(() => { - const content = editor.getContent(); - expect(content).toBe('goodbye world\ngoodbye again\ngoodbye there'); - expect(content).not.toContain('hello'); - // Should now have 0 matches - expect(container.textContent).toContain('/0'); - }); - }); - - test('Clicking the filter button opens the filter menu', async () => { - const { container } = await renderEditorAndOpenSearchPanel( - 'test content', - ); - - // Find and click the filter button - const filterButton = container.querySelector( - 'button[aria-label="filter button"]', - ) as HTMLButtonElement; - expect(filterButton).toBeInTheDocument(); - - await userEvent.click(filterButton); - - // Verify that the filter menu appears - await waitFor(() => { - // Check for menu items (Match case, Regexp, By word) - expect(container.textContent).toContain('Match case'); - expect(container.textContent).toContain('Regexp'); - expect(container.textContent).toContain('By word'); - }); + // Verify that indentation was reduced + await waitFor(() => { + const newContent = editor.getContent(); + // Should remove the tab indentation + expect(newContent).toBe('console.log("test");'); + expect(newContent).not.toBe(initialContent); + expect(newContent.length).toBeLessThan(initialContent.length); }); }); }); diff --git a/packages/code-editor/src/CodeEditor/CodeEditor.styles.ts b/packages/code-editor/src/CodeEditor/CodeEditor.styles.ts index 50628aa3f2..db20fd8ecd 100644 --- a/packages/code-editor/src/CodeEditor/CodeEditor.styles.ts +++ b/packages/code-editor/src/CodeEditor/CodeEditor.styles.ts @@ -14,7 +14,18 @@ import { Variant, } from '@leafygreen-ui/tokens'; -import { CodeEditorSelectors, CopyButtonAppearance } from './CodeEditor.types'; +import { PANEL_HEIGHT } from '../Panel'; + +import { + LINE_HEIGHT, + PADDING_BOTTOM, + PADDING_TOP, +} from './hooks/extensions/useThemeExtension'; +import { + CodeEditorProps, + CodeEditorSelectors, + CopyButtonAppearance, +} from './CodeEditor.types'; export const copyButtonClassName = createUniqueClassName( 'lg-code_editor-code_editor_copy_button', @@ -38,36 +49,43 @@ export const getEditorStyles = ({ maxHeight?: string; className?: string; copyButtonAppearance?: CopyButtonAppearance; -}) => - cx( +}) => { + return cx( { + // Dimensions [css` + height: ${height}; ${CodeEditorSelectors.Editor} { height: ${height}; } `]: !!height, [css` + max-height: ${maxHeight}; ${CodeEditorSelectors.Editor} { max-height: ${maxHeight}; } `]: !!maxHeight, [css` + min-height: ${minHeight}; ${CodeEditorSelectors.Editor} { min-height: ${minHeight}; } `]: !!minHeight, [css` + width: ${width}; ${CodeEditorSelectors.Editor} { width: ${width}; } `]: !!width, [css` + max-width: ${maxWidth}; ${CodeEditorSelectors.Editor} { max-width: ${maxWidth}; } `]: !!maxWidth, [css` + min-width: ${minWidth}; ${CodeEditorSelectors.Editor} { min-width: ${minWidth}; } @@ -85,6 +103,23 @@ export const getEditorStyles = ({ `, className, ); +}; + +function getHeight( + numOfLines: number, + baseFontSize: CodeEditorProps['baseFontSize'], +) { + const borders = 2; + const fontSize = baseFontSize ?? 13; + const numOfLinesForCalculation = numOfLines === 0 ? 1 : numOfLines; + + return ( + numOfLinesForCalculation * (fontSize * LINE_HEIGHT) + + PADDING_TOP + + PADDING_BOTTOM + + borders + ); +} export const getLoaderStyles = ({ theme, @@ -94,39 +129,57 @@ export const getLoaderStyles = ({ height, minHeight, maxHeight, + baseFontSize, + numOfLines, + isLoading, + hasPanel, }: { theme: Theme; + baseFontSize: CodeEditorProps['baseFontSize']; width?: string; minWidth?: string; maxWidth?: string; height?: string; minHeight?: string; maxHeight?: string; + numOfLines: number; + isLoading: boolean; + hasPanel: boolean; }) => { + const fontSize = baseFontSize ? baseFontSize : 13; + const defaultHeight = getHeight(numOfLines, fontSize); + + let heightValue = height; + + if (!heightValue) { + if (isLoading) { + heightValue = `${defaultHeight}px`; + } else { + heightValue = '100%'; + } + } + return css` background-color: ${color[theme].background[Variant.Primary][ InteractionState.Default ]}; border: 1px solid ${color[theme].border[Variant.Secondary][InteractionState.Default]}; - border-radius: ${borderRadius[300]}px; + border-top-left-radius: ${hasPanel ? 0 : borderRadius[300]}px; + border-top-right-radius: ${hasPanel ? 0 : borderRadius[300]}px; + border-bottom-left-radius: ${borderRadius[300]}px; + border-bottom-right-radius: ${borderRadius[300]}px; display: flex; align-items: center; justify-content: center; position: absolute; - top: 0; + top: ${hasPanel ? PANEL_HEIGHT : 0}px; width: ${width || '100%'}; max-width: ${maxWidth || 'none'}; min-width: ${minWidth || 'none'}; - height: ${height || '100%'}; + height: ${heightValue}; max-height: ${maxHeight || 'none'}; - /** - * The editor being rendered depends on a lazy loaded module, so it has no - * height until it loads. By default, its height expands to fit its - * content, therefore we won't know its actual height until it loads. - * To ensure the loader is visible, we need to set an arbitrary min height. - */ - min-height: ${minHeight || '234px'}; + min-height: ${minHeight || 'none'}; z-index: 1; `; }; diff --git a/packages/code-editor/src/CodeEditor/CodeEditor.testUtils.tsx b/packages/code-editor/src/CodeEditor/CodeEditor.testUtils.tsx index 3f8bbb7c58..1cfa9db8de 100644 --- a/packages/code-editor/src/CodeEditor/CodeEditor.testUtils.tsx +++ b/packages/code-editor/src/CodeEditor/CodeEditor.testUtils.tsx @@ -1,8 +1,11 @@ import React from 'react'; import { indentUnit } from '@codemirror/language'; import { type ChangeSpec } from '@codemirror/state'; -import { render, waitFor } from '@testing-library/react'; +import { render } from '@testing-library/react'; +import { preLoadedModules } from '../testing/preLoadedModules'; + +import { type CodeEditorModules } from './hooks'; import { CodeEditor, CodeEditorProps, @@ -11,36 +14,13 @@ import { } from '.'; let editorViewInstance: CodeMirrorView | null = null; -let getEditorViewFn: (() => CodeMirrorView | null) | null = null; let editorHandleInstance: any = null; -/** - * Waits for the editor view to be available - * @param timeout - Maximum time to wait in milliseconds (default: 5000) - * @returns Promise that resolves when the editor view is available - * @throws Error if timeout is reached - */ -async function waitForEditorView(timeout = 5000): Promise { - await waitFor( - () => { - const view = getEditorViewFn?.(); - - if (!view) { - throw new Error('Editor view not available yet'); - } - editorViewInstance = view; - }, - { timeout }, - ); - - return ensureEditorView(); -} - /** * Ensures the editor view is available, throwing an error if not * @throws Error if editor view is not available */ -function ensureEditorView(): CodeMirrorView { +function getEditorView(): CodeMirrorView { if (!editorViewInstance) { throw new Error( 'Editor view is not available. Make sure to call renderCodeEditor first and wait for the editor to initialize.', @@ -62,7 +42,7 @@ function getBySelector( selector: CodeEditorSelectors, options?: { text?: string }, ) { - const view = ensureEditorView(); + const view = getEditorView(); const elements = view.dom.querySelectorAll(selector); if (!elements || elements.length === 0) { @@ -107,7 +87,7 @@ function queryBySelector( selector: CodeEditorSelectors, options?: { text?: string }, ) { - const view = ensureEditorView(); + const view = getEditorView(); const elements = view.dom.querySelectorAll(selector); if (!elements || elements.length === 0) { @@ -142,7 +122,7 @@ function queryBySelector( * @returns Boolean indicating whether the editor is in read-only mode */ function isReadOnly() { - const view = ensureEditorView(); + const view = getEditorView(); return view.state.readOnly; } @@ -151,7 +131,7 @@ function isReadOnly() { * @returns The string used for indentation (spaces or tab) */ function getIndentUnit() { - const view = ensureEditorView(); + const view = getEditorView(); return view.state.facet(indentUnit); } @@ -169,7 +149,7 @@ function isLineWrappingEnabled() { * @throws Error if editor view is not initialized */ function getContent(): string { - const view = ensureEditorView(); + const view = getEditorView(); return view.state.doc.toString(); } @@ -197,7 +177,7 @@ function getHandle(): any { * @throws Error if editor view is not initialized */ function insertText(text: string, options?: { from?: number; to?: number }) { - const view = ensureEditorView(); + const view = getEditorView(); // Default to inserting at the end of the document const defaultFrom = options?.from ?? view.state.doc.length; @@ -257,11 +237,11 @@ export const editor = { undo, redo, }, - waitForEditorView, }; /** - * Renders a CodeEditor component with the specified props for testing + * Renders a CodeEditor component with the specified props for testing. + * Automatically provides preloaded modules for synchronous rendering. * @param props - Props to pass to the CodeEditor component * @param options - Optional rendering options * @param options.children - Children to render inside the CodeEditor (e.g., Panel components) @@ -269,20 +249,17 @@ export const editor = { */ export function renderCodeEditor( props: Partial = {}, - options?: { children?: React.ReactNode }, + moduleOverrides?: Partial, ) { - const { children } = options || {}; const { container } = render( { - getEditorViewFn = ref?.getEditorViewInstance ?? null; editorViewInstance = ref?.getEditorViewInstance() ?? null; editorHandleInstance = ref; }} - > - {children} - , + />, ); return { container, editor }; diff --git a/packages/code-editor/src/CodeEditor/CodeEditor.tsx b/packages/code-editor/src/CodeEditor/CodeEditor.tsx index 7def42d257..ca509592e1 100644 --- a/packages/code-editor/src/CodeEditor/CodeEditor.tsx +++ b/packages/code-editor/src/CodeEditor/CodeEditor.tsx @@ -8,7 +8,9 @@ import React, { } from 'react'; import { type EditorView, type ViewUpdate } from '@codemirror/view'; -import { useDarkMode } from '@leafygreen-ui/leafygreen-provider'; +import LeafyGreenProvider, { + useDarkMode, +} from '@leafygreen-ui/leafygreen-provider'; import { findChild } from '@leafygreen-ui/lib'; import { Body, useUpdatedBaseFontSize } from '@leafygreen-ui/typography'; @@ -85,6 +87,8 @@ const BaseCodeEditor = forwardRef( const isControlled = value !== undefined; const editorContainerRef = useRef(null); const editorViewRef = useRef(null); + const [undoDepth, setUndoDepth] = useState(0); + const [redoDepth, setRedoDepth] = useState(0); const { modules, isLoading } = useModules(props); @@ -115,6 +119,9 @@ const BaseCodeEditor = forwardRef( hasPanel: !!panel, }); + // Track whether extensions have been initialized + const [extensionsInitialized, setExtensionsInitialized] = useState(false); + // Get the current contents of the editor const getContents = useCallback(() => { return editorViewRef.current?.state.sliceDoc() ?? ''; @@ -174,13 +181,27 @@ const BaseCodeEditor = forwardRef( */ const handleUndo = useCallback((): boolean => { const commands = modules?.['@codemirror/commands']; + const EditorView = modules?.['@codemirror/view']; - if (!editorViewRef.current || !commands) { + if (!editorViewRef.current || !commands || !EditorView) { console.warn('Undo is not available - editor or commands not loaded'); return false; } - return commands.undo(editorViewRef.current); + const result = commands.undo(editorViewRef.current); + + // Focus the editor and scroll cursor into view after undo + if (result && editorViewRef.current) { + editorViewRef.current.focus(); + editorViewRef.current.dispatch({ + effects: EditorView.EditorView.scrollIntoView( + editorViewRef.current.state.selection.main, + { y: 'center' }, + ), + }); + } + + return result; }, [modules]); /** @@ -189,13 +210,27 @@ const BaseCodeEditor = forwardRef( */ const handleRedo = useCallback((): boolean => { const commands = modules?.['@codemirror/commands']; + const EditorView = modules?.['@codemirror/view']; - if (!editorViewRef.current || !commands) { + if (!editorViewRef.current || !commands || !EditorView) { console.warn('Redo is not available - editor or commands not loaded'); return false; } - return commands.redo(editorViewRef.current); + const result = commands.redo(editorViewRef.current); + + // Focus the editor and scroll cursor into view after redo + if (result && editorViewRef.current) { + editorViewRef.current.focus(); + editorViewRef.current.dispatch({ + effects: EditorView.EditorView.scrollIntoView( + editorViewRef.current.state.selection.main, + { y: 'center' }, + ), + }); + } + + return result; }, [modules]); /** @@ -256,22 +291,59 @@ const BaseCodeEditor = forwardRef( hasPanel: !!panel, }); + // Create the editor when modules are loaded useLayoutEffect(() => { const EditorView = modules?.['@codemirror/view']; - const commands = modules?.['@codemirror/commands']; - const searchModule = modules?.['@codemirror/search']; - const Prec = modules?.['@codemirror/state']?.Prec; - if (!editorContainerRef?.current || !EditorView || !Prec || !commands) { + if (!editorContainerRef?.current || !EditorView) { return; } const domNode = editorContainerRef.current as HTMLElementWithCodeMirror; + // Reset extensions initialized state + setExtensionsInitialized(false); + + // Create editor with minimal setup - extensions will be configured in separate effect editorViewRef.current = new EditorView.EditorView({ doc: controlledValue || defaultValue, parent: domNode, - extensions: [ + }); + + return () => { + /** Delete the CodeMirror instance from the DOM node */ + delete domNode._cm; + editorViewRef.current?.destroy(); + }; + // Only recreate editor when modules are loaded + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [modules]); + + /** + * Configure/update extensions whenever relevant props change. + * Extensions are configured in a separate effect so that the editor is not recreated every time a prop changes. + */ + useLayoutEffect(() => { + const EditorView = modules?.['@codemirror/view']; + const commands = modules?.['@codemirror/commands']; + const searchModule = modules?.['@codemirror/search']; + const Prec = modules?.['@codemirror/state']?.Prec; + const StateEffect = modules?.['@codemirror/state']?.StateEffect; + + if ( + !editorViewRef.current || + !EditorView || + !Prec || + !commands || + !searchModule || + !StateEffect + ) { + return; + } + + // Configure the editor with necessary extensions + editorViewRef.current.dispatch({ + effects: StateEffect.reconfigure.of([ ...consumerExtensions.map((extension: CodeMirrorExtension) => Prec.highest(extension), ), @@ -281,10 +353,20 @@ const BaseCodeEditor = forwardRef( searchPanelExtension, EditorView.EditorView.updateListener.of((update: ViewUpdate) => { - if (isControlled && update.docChanged) { - const editorText = getContents(); - onChangeProp?.(editorText); - setControlledValue(editorText); + if (update.docChanged) { + const commands = modules?.['@codemirror/commands']; + const state = editorViewRef.current?.state; + + if (isControlled) { + const editorText = getContents(); + onChangeProp?.(editorText); + setControlledValue(editorText); + } + + if (commands && state) { + setUndoDepth(commands.undoDepth(state)); + setRedoDepth(commands.redoDepth(state)); + } } }), @@ -313,7 +395,16 @@ const BaseCodeEditor = forwardRef( ]), ...customExtensions, - ], + ]), + }); + + /** + * Wait for next frame to ensure extensions are rendered before hiding loading overlay. + * Since the editor is created in a separate effect without extensions, it is created before the extensions + * are applied. This prevents a blink of an unstyled editor. + */ + const rafId = requestAnimationFrame(() => { + setExtensionsInitialized(true); }); if (forceParsingProp) { @@ -326,26 +417,21 @@ const BaseCodeEditor = forwardRef( } return () => { - /** Delete the CodeMirror instance from the DOM node */ - delete domNode._cm; - editorViewRef.current?.destroy(); + cancelAnimationFrame(rafId); }; }, [ - value, - modules, - controlledValue, - defaultValue, - isControlled, - onChangeProp, consumerExtensions, customExtensions, forceParsingProp, getContents, enableSearchPanel, - props.darkMode, - props.baseFontSize, + darkModeProp, + baseFontSizeProp, panel, searchPanelExtension, + isControlled, + modules, + onChangeProp, ]); useImperativeHandle(forwardedRef, () => ({ @@ -364,69 +450,95 @@ const BaseCodeEditor = forwardRef( isFormattingAvailable, language, undo: handleUndo, + undoDepth, redo: handleRedo, + redoDepth, downloadContent: handleDownloadContent, lgIds, + maxWidth, + minWidth, + width, + readOnly, + darkMode, + baseFontSize, + isLoading: isLoadingProp || isLoading || !extensionsInitialized, }; + const numOfLines = ( + value ?? + defaultValue ?? + (typeof placeholder === 'string' ? placeholder : '') + ).split('\n').length; + return ( - -
- {panel && ( -
- - {panel} - -
- )} - {!panel && - (copyButtonAppearance === CopyButtonAppearance.Hover || - copyButtonAppearance === CopyButtonAppearance.Persist) && ( - +
+ {panel && ( +
+ + {panel} + +
+ )} + {!panel && + (copyButtonAppearance === CopyButtonAppearance.Hover || + copyButtonAppearance === CopyButtonAppearance.Persist) && ( + + )} + {(isLoadingProp || isLoading || !extensionsInitialized) && ( +
+ + Loading code editor... + +
)} - {(isLoadingProp || isLoading) && ( -
- - Loading code editor... - -
- )} -
- +
+
+ ); }, ); diff --git a/packages/code-editor/src/CodeEditor/CodeEditor.types.ts b/packages/code-editor/src/CodeEditor/CodeEditor.types.ts index 4e5a471732..8323e16283 100644 --- a/packages/code-editor/src/CodeEditor/CodeEditor.types.ts +++ b/packages/code-editor/src/CodeEditor/CodeEditor.types.ts @@ -88,10 +88,11 @@ export const CodeEditorSelectors = { Editor: '.cm-editor', Focused: '.cm-focused', FoldGutter: '.cm-foldGutter', + FoldPlaceholder: '.cm-foldPlaceholder', GutterElement: '.cm-gutterElement', Gutters: '.cm-gutters', HyperLink: '.cm-hyper-link-icon', - InnerEditor: '.cm-scroller', + Scroller: '.cm-scroller', Line: '.cm-line', LineNumbers: '.cm-lineNumbers', LineWrapping: '.cm-lineWrapping', diff --git a/packages/code-editor/src/CodeEditor/CodeEditorContext.tsx b/packages/code-editor/src/CodeEditor/CodeEditorContext.tsx index 38797a1bdf..a5cc9e26b7 100644 --- a/packages/code-editor/src/CodeEditor/CodeEditorContext.tsx +++ b/packages/code-editor/src/CodeEditor/CodeEditorContext.tsx @@ -1,13 +1,24 @@ import React, { createContext, useContext } from 'react'; +import { DarkModeProps } from '@leafygreen-ui/lib'; +import { BaseFontSize } from '@leafygreen-ui/tokens'; + import { getLgIds, type GetLgIdsReturnType } from '../utils/getLgIds'; import { type LanguageName } from './hooks/extensions/useLanguageExtension'; +import { CodeEditorProps } from './CodeEditor.types'; + +interface BaseFontSizeProps { + baseFontSize: BaseFontSize | 14 | undefined; +} /** * Internal context values provided by CodeEditor to its children (like Panel). */ -export interface CodeEditorContextValue { +export interface CodeEditorContextValue + extends DarkModeProps, + BaseFontSizeProps, + Pick { /** * Function to retrieve the current editor contents. */ @@ -53,6 +64,21 @@ export interface CodeEditorContextValue { * to inherit custom data-lgid prefixes passed to the parent CodeEditor. */ lgIds: GetLgIdsReturnType; + + /** + * Depth of the undo stack. + */ + undoDepth: number; + + /** + * Depth of the redo stack. + */ + redoDepth: number; + + /** + * Stateful boolean indicating if the editor is loading. + */ + isLoading: boolean; } // Default context value for when Panel is used standalone @@ -67,6 +93,15 @@ const defaultContextValue: CodeEditorContextValue = { console.warn('downloadContent is not available - editor context not found'); }, lgIds: getLgIds(), // Use default lgIds when used standalone + maxWidth: undefined, + minWidth: undefined, + width: undefined, + readOnly: false, + undoDepth: 0, + redoDepth: 0, + baseFontSize: 13, + darkMode: false, + isLoading: false, }; const CodeEditorContext = diff --git a/packages/code-editor/src/CodeEditor/hooks/extensions/useCodeFoldingExtension.tsx b/packages/code-editor/src/CodeEditor/hooks/extensions/useCodeFoldingExtension.tsx index 6894a8b34d..b28067d9f8 100644 --- a/packages/code-editor/src/CodeEditor/hooks/extensions/useCodeFoldingExtension.tsx +++ b/packages/code-editor/src/CodeEditor/hooks/extensions/useCodeFoldingExtension.tsx @@ -66,7 +66,10 @@ export function useCodeFoldingExtension({ glyph="ChevronRight" size={CUSTOM_ICON_SIZE} className={css` - margin-top: ${spacing[100]}px; + /** + * Design indicated that the close icon seemed a bit unaligned at 4px, so we added 1px to better align + */ + margin-top: ${spacing[100] + 1}px; `} /> ), diff --git a/packages/code-editor/src/CodeEditor/hooks/extensions/useExtensions.spec.ts b/packages/code-editor/src/CodeEditor/hooks/extensions/useExtensions.spec.ts index f34fab0693..da317f6b39 100644 --- a/packages/code-editor/src/CodeEditor/hooks/extensions/useExtensions.spec.ts +++ b/packages/code-editor/src/CodeEditor/hooks/extensions/useExtensions.spec.ts @@ -1,16 +1,15 @@ import { renderHook } from '@leafygreen-ui/testing-lib'; -import { createComprehensiveFakeModules } from '../hooks.testUtils'; +import { preLoadedModules } from '../../../testing/preLoadedModules'; import { useExtensions } from './useExtensions'; describe('useExtensions (aggregator)', () => { - const fakeModules = createComprehensiveFakeModules(); - test('returns an array of extensions in expected order/length', () => { const { result } = renderHook(() => useExtensions({ editorViewInstance: null, + hasPanel: false, props: { enableLineNumbers: true, enableLineWrapping: true, @@ -18,7 +17,7 @@ describe('useExtensions (aggregator)', () => { readOnly: true, language: 'javascript', }, - modules: fakeModules, + modules: preLoadedModules, }), ); diff --git a/packages/code-editor/src/CodeEditor/hooks/extensions/useExtensions.ts b/packages/code-editor/src/CodeEditor/hooks/extensions/useExtensions.ts index d02a1e6259..709a0cb326 100644 --- a/packages/code-editor/src/CodeEditor/hooks/extensions/useExtensions.ts +++ b/packages/code-editor/src/CodeEditor/hooks/extensions/useExtensions.ts @@ -1,3 +1,4 @@ +import { useMemo } from 'react'; import { type EditorView } from 'codemirror'; import { useUpdatedBaseFontSize } from '@leafygreen-ui/typography'; @@ -52,7 +53,7 @@ export function useExtensions({ modules: Partial; hasPanel: boolean; }) { - const baseFontSize = useUpdatedBaseFontSize(); + const baseFontSize = useUpdatedBaseFontSize(props.baseFontSize); const autoCompleteExtension = useAutoCompleteExtension({ editorViewInstance, @@ -130,18 +131,34 @@ export function useExtensions({ modules, }); - return [ - autoCompleteExtension, - highlightExtension, - hyperLinkExtension, - lineWrapExtension, - lineNumbersExtension, - codeFoldingExtension, // Order matters here, code folding must be after line numbers - indentExtension, - placeholderExtension, - tooltipExtension, - languageExtension, - themeExtension, - readOnlyExtension, - ]; + return useMemo( + () => [ + autoCompleteExtension, + highlightExtension, + hyperLinkExtension, + lineWrapExtension, + lineNumbersExtension, + codeFoldingExtension, // Order matters here, code folding must be after line numbers + indentExtension, + placeholderExtension, + tooltipExtension, + languageExtension, + themeExtension, + readOnlyExtension, + ], + [ + autoCompleteExtension, + highlightExtension, + hyperLinkExtension, + lineWrapExtension, + lineNumbersExtension, + codeFoldingExtension, + indentExtension, + placeholderExtension, + tooltipExtension, + languageExtension, + themeExtension, + readOnlyExtension, + ], + ); } diff --git a/packages/code-editor/src/CodeEditor/hooks/extensions/useReadOnlyExtension.spec.ts b/packages/code-editor/src/CodeEditor/hooks/extensions/useReadOnlyExtension.spec.ts index 95eeb5f809..79851dacf9 100644 --- a/packages/code-editor/src/CodeEditor/hooks/extensions/useReadOnlyExtension.spec.ts +++ b/packages/code-editor/src/CodeEditor/hooks/extensions/useReadOnlyExtension.spec.ts @@ -1,21 +1,24 @@ -import { renderHook } from '@leafygreen-ui/testing-lib'; +import * as StateModule from '@codemirror/state'; +import * as ViewModule from '@codemirror/view'; -import { createMockStateModule } from '../hooks.testUtils'; +import { renderHook } from '@leafygreen-ui/testing-lib'; import { useReadOnlyExtension } from './useReadOnlyExtension'; describe('useReadOnlyExtension', () => { - const fakeStateModule = createMockStateModule(); - test('returns empty when readOnly is false', () => { const { result } = renderHook(() => useReadOnlyExtension({ editorViewInstance: null, props: { readOnly: false }, - modules: { '@codemirror/state': fakeStateModule }, + modules: { + '@codemirror/state': StateModule, + '@codemirror/view': ViewModule, + }, }), ); - expect(result.current).toEqual([]); + const current = result.current as any; + expect(current.inner).toEqual([]); }); test('returns readonly extension when enabled', () => { @@ -23,9 +26,13 @@ describe('useReadOnlyExtension', () => { useReadOnlyExtension({ editorViewInstance: null, props: { readOnly: true }, - modules: { '@codemirror/state': fakeStateModule }, + modules: { + '@codemirror/state': StateModule, + '@codemirror/view': ViewModule, + }, }), ); - expect(result.current).toBe('READONLY_true'); + const current = result.current as any; + expect(current.inner.length).toBeGreaterThan(0); // CodeMirrorcompartment was created }); }); diff --git a/packages/code-editor/src/CodeEditor/hooks/extensions/useReadOnlyExtension.ts b/packages/code-editor/src/CodeEditor/hooks/extensions/useReadOnlyExtension.ts index 35f1fa2b1c..118e1fa32b 100644 --- a/packages/code-editor/src/CodeEditor/hooks/extensions/useReadOnlyExtension.ts +++ b/packages/code-editor/src/CodeEditor/hooks/extensions/useReadOnlyExtension.ts @@ -33,8 +33,16 @@ export function useReadOnlyExtension({ value: { enable: props.readOnly, module: modules?.['@codemirror/state'], + EditorViewModule: modules?.['@codemirror/view'], }, - factory: ({ enable, module }) => - enable && module ? module.EditorState.readOnly.of(true) : [], + factory: ({ enable, module, EditorViewModule }) => + enable && module && EditorViewModule + ? [ + // Prevents editing + module.EditorState.readOnly.of(true), + // Prevents cursor from blinking + EditorViewModule.EditorView.editable.of(false), + ] + : [], }); } diff --git a/packages/code-editor/src/CodeEditor/hooks/extensions/useThemeExtension.ts b/packages/code-editor/src/CodeEditor/hooks/extensions/useThemeExtension.ts index d1054f979e..a34f4c3eb6 100644 --- a/packages/code-editor/src/CodeEditor/hooks/extensions/useThemeExtension.ts +++ b/packages/code-editor/src/CodeEditor/hooks/extensions/useThemeExtension.ts @@ -20,6 +20,11 @@ import { type CodeEditorModules } from '../moduleLoaders.types'; import { useExtension } from './useExtension'; +// Exported so that an estimated height can be calculated for the editor while it's loading +export const LINE_HEIGHT = 1.5; +export const PADDING_TOP = spacing[200]; +export const PADDING_BOTTOM = spacing[200]; + /** * Hook for applying LeafyGreen UI theme styling to the CodeMirror editor. * @@ -86,14 +91,19 @@ export function useThemeExtension({ ${color[theme].border[Variant.Secondary][InteractionState.Default]}`, }, - [CodeEditorSelectors.InnerEditor]: { - paddingTop: `${spacing[200]}px`, - paddingBottom: `${spacing[200]}px`, + [CodeEditorSelectors.Scroller]: { + paddingTop: `${PADDING_TOP}px`, + paddingBottom: `${PADDING_BOTTOM}px`, + }, + + [CodeEditorSelectors.FoldPlaceholder]: { + background: 'transparent', }, [CodeEditorSelectors.Content]: { fontFamily: fontFamilies.code, fontSize: `${fontSize}px`, + padding: '0px', }, [CodeEditorSelectors.Gutters]: { @@ -114,9 +124,17 @@ export function useThemeExtension({ { width: '48px', userSelect: 'none', + // Set on the fold gutter element instead so there's still padding when line numbers are disabled + paddingRight: 0, + }, + + [`${CodeEditorSelectors.FoldGutter} ${CodeEditorSelectors.GutterElement}`]: + { + paddingLeft: `${spacing[100]}px`, }, [CodeEditorSelectors.Line]: { + lineHeight: LINE_HEIGHT, paddingLeft: `${spacing[300]}px`, }, diff --git a/packages/code-editor/src/CodeEditor/hooks/extensions/useTooltipExtension.ts b/packages/code-editor/src/CodeEditor/hooks/extensions/useTooltipExtension.ts index a84a281de3..81a03bf646 100644 --- a/packages/code-editor/src/CodeEditor/hooks/extensions/useTooltipExtension.ts +++ b/packages/code-editor/src/CodeEditor/hooks/extensions/useTooltipExtension.ts @@ -47,44 +47,53 @@ export function useTooltipExtension({ return []; } - return module.linter(linterView => { - const diagnostics: Array = tooltips.map( - ({ - line, - column = 1, - severity = 'info', - length, - messages, - links, - }: CodeEditorTooltipType) => { - const lineInfo = linterView.state.doc.line(line); - const from = lineInfo.from + column - 1; - const to = from + length; + return module.linter( + linterView => { + const diagnostics: Array = tooltips.map( + ({ + line, + column = 1, + severity = 'info', + length, + messages, + links, + }: CodeEditorTooltipType) => { + const lineInfo = linterView.state.doc.line(line); + const from = lineInfo.from + column - 1; + const to = from + length; - const renderMessage = () => { - const dom = document.createElement('div'); - dom.innerHTML = renderToString( - React.createElement(CodeEditorTooltip, { - messages, - links, - darkMode: props.darkMode, - baseFontSize: props.baseFontSize, - }), - ); - return dom; - }; + const renderMessage = () => { + const dom = document.createElement('div'); + dom.innerHTML = renderToString( + React.createElement(CodeEditorTooltip, { + messages, + links, + darkMode: props.darkMode, + baseFontSize: props.baseFontSize, + }), + ); + return dom; + }; - return { - from, - to, - severity, - message: ' ', // Provide a non-empty string to satisfy Diagnostic type - renderMessage, - }; - }, - ); - return diagnostics; - }); + return { + from, + to, + severity, + message: ' ', // Provide a non-empty string to satisfy Diagnostic type + renderMessage, + }; + }, + ); + return diagnostics; + }, + { + /** + * Decreasing but if consumers decide to use this for live linting, we might want to revert this so it + * doesn't show errors too fast. + */ + delay: 100, + }, + ); }, }); } diff --git a/packages/code-editor/src/CodeEditorCopyButton/CodeEditorCopyButton.styles.ts b/packages/code-editor/src/CodeEditorCopyButton/CodeEditorCopyButton.styles.ts index 5e0db7acc0..f26c2d16f4 100644 --- a/packages/code-editor/src/CodeEditorCopyButton/CodeEditorCopyButton.styles.ts +++ b/packages/code-editor/src/CodeEditorCopyButton/CodeEditorCopyButton.styles.ts @@ -42,7 +42,10 @@ export const getCopyButtonStyles = ({ height: 26px; } - transition: all ${transitionDuration.default}ms ease-in-out; + &, + & > div > svg { + transition: all ${transitionDuration.default}ms ease-in-out; + } `, { [copiedThemeStyle[theme]]: copied, @@ -57,7 +60,16 @@ export const getCopyButtonStyles = ({ */ export const copiedThemeStyle: Record = { [Theme.Light]: css` - color: ${palette.white}; + &, + & > div > svg { + color: ${palette.white}; + + &:focus, + &:hover { + color: ${palette.white}; + } + } + background-color: ${palette.green.dark1}; &:focus, @@ -71,7 +83,16 @@ export const copiedThemeStyle: Record = { } `, [Theme.Dark]: css` - color: ${palette.gray.dark3}; + &, + & > div > svg { + color: ${palette.gray.dark3}; + + &:focus, + &:hover { + color: ${palette.gray.dark3}; + } + } + background-color: ${palette.green.base}; &:focus, diff --git a/packages/code-editor/src/CodeEditorCopyButton/CodeEditorCopyButton.tsx b/packages/code-editor/src/CodeEditorCopyButton/CodeEditorCopyButton.tsx index 2177181876..f520f42e9e 100644 --- a/packages/code-editor/src/CodeEditorCopyButton/CodeEditorCopyButton.tsx +++ b/packages/code-editor/src/CodeEditorCopyButton/CodeEditorCopyButton.tsx @@ -178,6 +178,7 @@ export function CodeEditorCopyButton({ open={tooltipOpen} renderMode={RenderMode.TopLayer} setOpen={setTooltipOpen} + darkMode={theme === 'dark'} trigger={ (null); const menuRef = useRef(null); + /** + * Prevent mousedown from changing selection when opening context menu. + * Uses capture phase to intercept before CodeMirror handles it. + */ + useEffect(() => { + const handleMouseDown = (event: MouseEvent) => { + // Check if this will trigger a context menu + const isRightClick = event.button === 2; + const isCtrlClick = event.ctrlKey && event.button === 0; + + if (isRightClick || isCtrlClick) { + const target = event.target as Element; + + // Only prevent if within our container and not in a no-context-menu zone + if ( + containerRef.current && + containerRef.current.contains(target) && + !target.closest('[data-no-context-menu="true"]') + ) { + event.preventDefault(); + event.stopPropagation(); + } + } + }; + + // Use capture phase to intercept before CodeMirror + document.addEventListener('mousedown', handleMouseDown, { capture: true }); + + return () => { + document.removeEventListener('mousedown', handleMouseDown, { + capture: true, + }); + }; + }, []); + /** * Handle showing and positioning custom menu onContextMenu */ @@ -95,7 +130,7 @@ export const ContextMenu = ({ * before the refocus occurs by setting the open state to false and only * rendering the Menu when the open state is true. */ - const handleClick = useCallback( + const handleGlobalClick = useCallback( (e: MouseEvent) => { /** * Don't close if clicking inside the menu. @@ -132,15 +167,17 @@ export const ContextMenu = ({ document.addEventListener('contextmenu', handleGlobalContextMenu); /** * Must capture click to prevent default Menu handling of clicks. - * See {@link handleClick} comment for more details. + * See {@link handleGlobalClick} comment for more details. */ - document.addEventListener('click', handleClick, { capture: true }); + document.addEventListener('click', handleGlobalClick, { capture: true }); return () => { document.removeEventListener('contextmenu', handleGlobalContextMenu); - document.removeEventListener('click', handleClick, { capture: true }); + document.removeEventListener('click', handleGlobalClick, { + capture: true, + }); }; } - }, [isOpen, handleGlobalContextMenu, handleClick]); + }, [isOpen, handleGlobalContextMenu, handleGlobalClick]); return (
{ - const MockModal = ({ children, open, ...props }: any) => { + const MockModal = function ({ children, open, ...props }: any) { return open ? (
{children} @@ -15,7 +15,11 @@ jest.mock('@leafygreen-ui/modal', () => { ) : null; }; - return { Modal: MockModal }; + return { + __esModule: true, + default: MockModal, + Modal: MockModal, + }; }); const TestIcon = () =>
; diff --git a/packages/code-editor/src/Panel/Panel.styles.ts b/packages/code-editor/src/Panel/Panel.styles.ts index 5f03404d80..9ee9562a01 100644 --- a/packages/code-editor/src/Panel/Panel.styles.ts +++ b/packages/code-editor/src/Panel/Panel.styles.ts @@ -1,4 +1,4 @@ -import { css } from '@leafygreen-ui/emotion'; +import { css, cx } from '@leafygreen-ui/emotion'; import { Theme } from '@leafygreen-ui/lib'; import { borderRadius, @@ -8,29 +8,57 @@ import { Variant, } from '@leafygreen-ui/tokens'; -const PANEL_HEIGHT = 36; +/** + * The Loading div is absolutely positioned inside the code editor. When the panel isn't rendered, + * it renders at a `top: 0` position. When the panel is rendered, it needs to render at a + * `top: ${PANEL_HEIGHT}px` position so it doesn't overlap with the panel. So this is exported. + */ +export const PANEL_HEIGHT = 36; + const MODAL_HEIGHT = 354; -export const getPanelStyles = (theme: Theme) => { - return css` - background-color: ${color[theme].background[Variant.Secondary][ - InteractionState.Default - ]}; - height: ${PANEL_HEIGHT}px; - width: 100%; - display: flex; - align-items: center; - justify-content: space-between; - padding: 0 ${spacing[400]}px 0 ${spacing[300]}px; - border: 1px solid - ${color[theme].border[Variant.Secondary][InteractionState.Default]}; - border-bottom: none; - border-top-left-radius: ${borderRadius[300]}px; - border-top-right-radius: ${borderRadius[300]}px; - display: grid; - grid-template-columns: auto 1fr auto; - grid-template-areas: 'title inner-content buttons'; - `; +const getBasePanelStyles = (theme: Theme) => css` + background-color: ${color[theme].background[Variant.Secondary][ + InteractionState.Default + ]}; + height: ${PANEL_HEIGHT}px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 ${spacing[400]}px 0 ${spacing[300]}px; + border: 1px solid + ${color[theme].border[Variant.Secondary][InteractionState.Default]}; + border-bottom: none; + border-top-left-radius: ${borderRadius[300]}px; + border-top-right-radius: ${borderRadius[300]}px; + display: grid; + grid-template-columns: auto 1fr auto; + grid-template-areas: 'title inner-content buttons'; + width: 100%; +`; + +export const getPanelStyles = ({ + theme, + width, + minWidth, + maxWidth, +}: { + theme: Theme; + width?: string; + minWidth?: string; + maxWidth?: string; +}) => { + return cx(getBasePanelStyles(theme), { + [css` + width: ${width}; + `]: !!width, + [css` + min-width: ${minWidth}; + `]: !!minWidth, + [css` + max-width: ${maxWidth}; + `]: !!maxWidth, + }); }; export const getPanelTitleStyles = (theme: Theme, baseFontSize: number) => { @@ -50,6 +78,8 @@ export const getPanelInnerContentStyles = () => { export const getPanelButtonsStyles = () => { return css` grid-area: buttons; + display: flex; + justify-content: center; `; }; diff --git a/packages/code-editor/src/Panel/Panel.testUtils.tsx b/packages/code-editor/src/Panel/Panel.testUtils.tsx index cffba711fe..491733d73b 100644 --- a/packages/code-editor/src/Panel/Panel.testUtils.tsx +++ b/packages/code-editor/src/Panel/Panel.testUtils.tsx @@ -42,6 +42,10 @@ export interface PanelTestContextConfig { redo?: () => boolean; downloadContent?: () => void; lgIds?: GetLgIdsReturnType; + undoDepth?: number; + redoDepth?: number; + baseFontSize?: 13 | 14 | 16 | undefined; + darkMode?: boolean; } /** @@ -68,6 +72,11 @@ export function renderPanel(config: RenderPanelConfig = {}) { redo: defaultStubRedo, downloadContent: defaultStubDownloadContent, lgIds: getLgIds(), + undoDepth: 1, + redoDepth: 1, + baseFontSize: 13 as const, + darkMode: false, + isLoading: false, ...contextConfig, }; diff --git a/packages/code-editor/src/Panel/Panel.tsx b/packages/code-editor/src/Panel/Panel.tsx index 765194b743..1554fc87b4 100644 --- a/packages/code-editor/src/Panel/Panel.tsx +++ b/packages/code-editor/src/Panel/Panel.tsx @@ -47,7 +47,7 @@ import { PanelProps } from './Panel.types'; export function Panel({ baseFontSize: baseFontSizeProp, customSecondaryButtons, - darkMode, + darkMode: darkModeProp, downloadFileName, innerContent, onCopyClick, @@ -63,11 +63,29 @@ export function Panel({ }: PanelProps) { const [shortcutsModalOpen, setShortcutsModalOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false); - const { theme } = useDarkMode(darkMode); - const baseFontSize = useUpdatedBaseFontSize(); - const { getContents, formatCode, undo, redo, downloadContent, lgIds } = - useCodeEditorContext(); + const { + baseFontSize: contextBaseFontSize, + darkMode: contextDarkMode, + downloadContent, + formatCode, + getContents, + isLoading, + lgIds, + maxWidth, + minWidth, + readOnly, + redo, + redoDepth, + undo, + undoDepth, + width, + } = useCodeEditorContext(); + + const { theme } = useDarkMode(darkModeProp || contextDarkMode); + const baseFontSize = useUpdatedBaseFontSize( + baseFontSizeProp || contextBaseFontSize, + ); const handleFormatClick = async () => { if (formatCode) { @@ -115,7 +133,10 @@ export function Panel({ return ( <> -
+
{showFormatButton && ( @@ -150,14 +175,17 @@ export function Panel({ getContentsToCopy={getContents ?? (() => '')} onCopy={onCopyClick} data-lgid={lgIds.panelCopyButton} + disabled={isLoading} /> )} {showSecondaryMenuButton && ( @@ -167,11 +195,13 @@ export function Panel({ open={menuOpen} setOpen={setMenuOpen} data-lgid={lgIds.panelSecondaryMenu} + darkMode={theme === 'dark'} > } onClick={handleUndoClick} aria-label="Undo changes" + disabled={readOnly || undoDepth === 0} > Undo @@ -179,6 +209,7 @@ export function Panel({ glyph={} onClick={handleRedoClick} aria-label="Redo changes" + disabled={readOnly || redoDepth === 0} > Redo @@ -225,6 +256,8 @@ export function Panel({ open={shortcutsModalOpen} setOpen={setShortcutsModalOpen} className={ModalStyles} + initialFocus="auto" + darkMode={theme === 'dark'} > diff --git a/packages/code-editor/src/Panel/index.ts b/packages/code-editor/src/Panel/index.ts index 8103a76a0a..6c975cc8c4 100644 --- a/packages/code-editor/src/Panel/index.ts +++ b/packages/code-editor/src/Panel/index.ts @@ -1,2 +1,3 @@ export { Panel } from './Panel'; +export { PANEL_HEIGHT } from './Panel.styles'; export { type PanelProps } from './Panel.types'; diff --git a/packages/code-editor/src/SearchPanel/SearchPanel.spec.tsx b/packages/code-editor/src/SearchPanel/SearchPanel.spec.tsx new file mode 100644 index 0000000000..182b638e88 --- /dev/null +++ b/packages/code-editor/src/SearchPanel/SearchPanel.spec.tsx @@ -0,0 +1,458 @@ +import { waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { renderCodeEditor } from '../CodeEditor/CodeEditor.testUtils'; +import { CodeEditorSelectors } from '../CodeEditor/CodeEditor.types'; + +const mockForceParsing = jest.fn(); + +jest.mock('@codemirror/language', () => ({ + ...jest.requireActual('@codemirror/language'), + forceParsing: (...args: Array) => mockForceParsing(...args), +})); + +// Enhanced MutationObserver mock for CodeMirror compatibility +global.MutationObserver = jest.fn().mockImplementation(() => ({ + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn(), + takeRecords: jest.fn().mockReturnValue([]), +})); + +// Mock ResizeObserver which is used by CodeMirror +global.ResizeObserver = jest.fn().mockImplementation(() => ({ + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn(), +})); + +// Mock IntersectionObserver which may be used by CodeMirror +global.IntersectionObserver = jest.fn().mockImplementation(() => ({ + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn(), + root: null, + rootMargin: '', + thresholds: [], +})); + +// Mock elementFromPoint which is used by CodeMirror for mouse position handling +if (!document.elementFromPoint) { + document.elementFromPoint = jest.fn(() => { + return document.body; + }); +} + +// Mock createRange for CodeMirror +if (!global.document.createRange) { + global.document.createRange = jest.fn().mockReturnValue({ + setStart: jest.fn(), + setEnd: jest.fn(), + collapse: jest.fn(), + selectNodeContents: jest.fn(), + insertNode: jest.fn(), + surroundContents: jest.fn(), + cloneRange: jest.fn(), + detach: jest.fn(), + getClientRects: jest.fn().mockReturnValue([]), + getBoundingClientRect: jest.fn().mockReturnValue({ + top: 0, + left: 0, + bottom: 0, + right: 0, + width: 0, + height: 0, + }), + }); +} + +// Mock getClientRects on Range prototype for CodeMirror search +if (typeof Range !== 'undefined' && !Range.prototype.getClientRects) { + Range.prototype.getClientRects = jest.fn().mockReturnValue([]); + Range.prototype.getBoundingClientRect = jest.fn().mockReturnValue({ + top: 0, + left: 0, + bottom: 0, + right: 0, + width: 0, + height: 0, + x: 0, + y: 0, + toJSON: () => ({}), + }); +} + +async function renderEditorAndOpenSearchPanel(defaultValue: string) { + const { editor, container } = renderCodeEditor({ + defaultValue, + }); + + // Focus the editor + const contentElement = editor.getBySelector(CodeEditorSelectors.Content); + await userEvent.click(contentElement); + + // Wait for editor to be focused + await waitFor(() => { + expect(container.querySelector('.cm-focused')).toBeInTheDocument(); + }); + + // Press Ctrl+F to open search + await userEvent.keyboard('{Control>}f{/Control}'); + + // Wait for search panel to appear + await waitFor(() => { + expect( + container.querySelector('input[placeholder="Find"]'), + ).toBeInTheDocument(); + }); + + return { editor, container }; +} + +describe('packages/code-editor/SearchPanel', () => { + test('Pressing CMD+F pulls up the search panel', async () => { + await renderEditorAndOpenSearchPanel('console.log("hello");'); + }); + + test('Pressing ESC closes the search panel', async () => { + const { container } = await renderEditorAndOpenSearchPanel( + 'console.log("hello");', + ); + + // Get the search input and focus it + const searchInput = container.querySelector( + 'input[placeholder="Find"]', + ) as HTMLInputElement; + expect(searchInput).toBeInTheDocument(); + + // Press ESC to close + await userEvent.click(searchInput); + await userEvent.keyboard('{Escape}'); + + // Verify search panel is closed + await waitFor(() => { + expect( + container.querySelector(CodeEditorSelectors.SearchPanel), + ).not.toBeInTheDocument(); + }); + }); + + test('Clicking the close button closes the search panel', async () => { + const { container } = await renderEditorAndOpenSearchPanel( + 'console.log("hello");', + ); + + // Find and click the close button (X icon button) + const closeButton = container.querySelector( + 'button[aria-label="close find menu button"]', + ) as HTMLButtonElement; + expect(closeButton).toBeInTheDocument(); + + await userEvent.click(closeButton); + + // Verify search panel is closed + await waitFor(() => { + expect( + container.querySelector(CodeEditorSelectors.SearchPanel), + ).not.toBeInTheDocument(); + }); + }); + + test('Clicking The ChevronDown expands the panel to show the replace panel', async () => { + const { container } = await renderEditorAndOpenSearchPanel( + 'console.log("hello");', + ); + + // Initially, replace section should be hidden (aria-hidden) + const replaceSection = container.querySelector('[aria-hidden="true"]'); + expect(replaceSection).toBeInTheDocument(); + + // Click the toggle button (ChevronDown) + const toggleButton = container.querySelector( + 'button[aria-label="Toggle button"]', + ) as HTMLButtonElement; + expect(toggleButton).toBeInTheDocument(); + expect(toggleButton.getAttribute('aria-expanded')).toBe('false'); + + await userEvent.click(toggleButton); + + // Verify the toggle button is expanded + await waitFor(() => { + expect(toggleButton.getAttribute('aria-expanded')).toBe('true'); + }); + + // Verify replace input is now accessible + const replaceInput = container.querySelector( + 'input[placeholder="Replace"]', + ) as HTMLInputElement; + expect(replaceInput).toBeInTheDocument(); + }); + + test('Pressing Enter after typing in the search input focuses the next match', async () => { + const { container } = await renderEditorAndOpenSearchPanel( + 'hello\nhello\nhello', + ); + + // Type in the search input + const searchInput = container.querySelector( + 'input[placeholder="Find"]', + ) as HTMLInputElement; + await userEvent.click(searchInput); + await userEvent.type(searchInput, 'hello'); + + // Wait for matches to be found + await waitFor(() => { + expect(searchInput.value).toBe('hello'); + expect(container.textContent).toContain('/3'); + }); + + // Press Enter to go to next match + await userEvent.keyboard('{Enter}'); + + // Verify that the selection moved (check for match count update and selected text) + await waitFor(() => { + // After pressing Enter, should move to first match + expect(container.textContent).toContain('1/3'); + + const selectedMatch = container.querySelector('.cm-searchMatch-selected'); + expect(selectedMatch).toBeInTheDocument(); + expect(selectedMatch?.innerHTML).toBe('hello'); + }); + }); + + test('Clicking the arrow down button focuses the next match', async () => { + const { container } = await renderEditorAndOpenSearchPanel( + 'test\ntest\ntest', + ); + + // Type in the search input + const searchInput = container.querySelector( + 'input[placeholder="Find"]', + ) as HTMLInputElement; + await userEvent.click(searchInput); + await userEvent.type(searchInput, 'test'); + + // Wait for matches to be found + await waitFor(() => { + expect(searchInput.value).toBe('test'); + expect(container.textContent).toContain('/3'); + }); + + // Click the arrow down button + const arrowDownButton = container.querySelector( + 'button[aria-label="next item button"]', + ) as HTMLButtonElement; + expect(arrowDownButton).toBeInTheDocument(); + + await userEvent.click(arrowDownButton); + + // Verify that the selection moved to the first match + await waitFor(() => { + expect(container.textContent).toContain('1/3'); + const selectedMatch = container.querySelector('.cm-searchMatch-selected'); + expect(selectedMatch).toBeInTheDocument(); + expect(selectedMatch?.innerHTML).toBe('test'); + }); + }); + + test('Pressing Shift+Enter after typing in the search input focuses the previous match', async () => { + const { container } = await renderEditorAndOpenSearchPanel( + 'hello\nhello\nhello', + ); + + // Type in the search input + const searchInput = container.querySelector( + 'input[placeholder="Find"]', + ) as HTMLInputElement; + await userEvent.click(searchInput); + await userEvent.type(searchInput, 'hello'); + + // Wait for matches to be found + await waitFor(() => { + expect(searchInput.value).toBe('hello'); + expect(container.textContent).toContain('/3'); + }); + + // Press Enter to go to first match + await userEvent.keyboard('{Enter}'); + + await waitFor(() => { + expect(container.textContent).toContain('1/3'); + }); + + // Press Shift+Enter to go to previous match (should wrap to last) + await userEvent.keyboard('{Shift>}{Enter}{/Shift}'); + + // Verify that the selection moved backwards + await waitFor(() => { + // Should wrap to last match (3) + expect(container.textContent).toContain('3/3'); + }); + }); + + test('Clicking the arrow up button focuses the previous match', async () => { + const { container } = await renderEditorAndOpenSearchPanel( + 'test\ntest\ntest', + ); + + // Type in the search input + const searchInput = container.querySelector( + 'input[placeholder="Find"]', + ) as HTMLInputElement; + await userEvent.click(searchInput); + await userEvent.type(searchInput, 'test'); + + // Wait for matches + await waitFor(() => { + expect(searchInput.value).toBe('test'); + expect(container.textContent).toContain('/3'); + }); + + // Click the arrow up button (should wrap to last match) + const arrowUpButton = container.querySelector( + 'button[aria-label="previous item button"]', + ) as HTMLButtonElement; + expect(arrowUpButton).toBeInTheDocument(); + + await userEvent.click(arrowUpButton); + + // Verify that the selection moved (should wrap to last match) + await waitFor(() => { + expect(container.textContent).toContain('3/3'); + }); + }); + + test('Clicking the replace button replaces the next match', async () => { + const { editor, container } = await renderEditorAndOpenSearchPanel( + 'hello world\nhello again', + ); + + // Expand to show replace panel + const toggleButton = container.querySelector( + 'button[aria-label="Toggle button"]', + ) as HTMLButtonElement; + await userEvent.click(toggleButton); + + await waitFor(() => { + expect(toggleButton.getAttribute('aria-expanded')).toBe('true'); + }); + + // Type search term + const searchInput = container.querySelector( + 'input[placeholder="Find"]', + ) as HTMLInputElement; + await userEvent.click(searchInput); + await userEvent.type(searchInput, 'hello'); + + // Wait for matches + await waitFor(() => { + expect(container.textContent).toContain('/2'); + }); + + // Type replace term + const replaceInput = container.querySelector( + 'input[placeholder="Replace"]', + ) as HTMLInputElement; + await userEvent.click(replaceInput); + await userEvent.type(replaceInput, 'goodbye'); + + // Find first match + const arrowDownButton = container.querySelector( + 'button[aria-label="next item button"]', + ) as HTMLButtonElement; + await userEvent.click(arrowDownButton); + + await waitFor(() => { + expect(container.textContent).toContain('1/2'); + }); + + // Click replace button + const replaceButton = container.querySelector( + 'button[aria-label="replace button"]', + ) as HTMLButtonElement; + expect(replaceButton).toBeInTheDocument(); + + await userEvent.click(replaceButton); + + // Verify that one match was replaced + await waitFor(() => { + const content = editor.getContent(); + expect(content).toContain('goodbye world'); + expect(content).toContain('hello again'); + // Should now only have 1 match left + expect(container.textContent).toContain('/1'); + }); + }); + + test('Clicking the replace all button replaces all matches', async () => { + const { editor, container } = await renderEditorAndOpenSearchPanel( + 'hello world\nhello again\nhello there', + ); + + // Expand to show replace panel + const toggleButton = container.querySelector( + 'button[aria-label="Toggle button"]', + ) as HTMLButtonElement; + await userEvent.click(toggleButton); + + await waitFor(() => { + expect(toggleButton.getAttribute('aria-expanded')).toBe('true'); + }); + + // Type search term + const searchInput = container.querySelector( + 'input[placeholder="Find"]', + ) as HTMLInputElement; + await userEvent.click(searchInput); + await userEvent.type(searchInput, 'hello'); + + // Wait for matches + await waitFor(() => { + expect(container.textContent).toContain('/3'); + }); + + // Type replace term + const replaceInput = container.querySelector( + 'input[placeholder="Replace"]', + ) as HTMLInputElement; + await userEvent.click(replaceInput); + await userEvent.type(replaceInput, 'goodbye'); + + // Click replace all button + const replaceAllButton = container.querySelector( + 'button[aria-label="replace all button"]', + ) as HTMLButtonElement; + expect(replaceAllButton).toBeInTheDocument(); + + await userEvent.click(replaceAllButton); + + // Verify that all matches were replaced + await waitFor(() => { + const content = editor.getContent(); + expect(content).toBe('goodbye world\ngoodbye again\ngoodbye there'); + expect(content).not.toContain('hello'); + // Should now have 0 matches + expect(container.textContent).toContain('/0'); + }); + }); + + test('Clicking the filter button opens the filter menu', async () => { + const { container } = await renderEditorAndOpenSearchPanel('test content'); + + // Find and click the filter button + const filterButton = container.querySelector( + 'button[aria-label="filter button"]', + ) as HTMLButtonElement; + expect(filterButton).toBeInTheDocument(); + + await userEvent.click(filterButton); + + // Verify that the filter menu appears + await waitFor(() => { + // Check for menu items (Match case, Regexp, By word) + expect(container.textContent).toContain('Match case'); + expect(container.textContent).toContain('Regexp'); + expect(container.textContent).toContain('By word'); + }); + }); +}); diff --git a/packages/code-editor/src/SearchPanel/SearchPanel.tsx b/packages/code-editor/src/SearchPanel/SearchPanel.tsx index 521826678f..674f506460 100644 --- a/packages/code-editor/src/SearchPanel/SearchPanel.tsx +++ b/packages/code-editor/src/SearchPanel/SearchPanel.tsx @@ -368,7 +368,7 @@ export function SearchPanel({
diff --git a/packages/code-editor/src/testing/codeSnippets.ts b/packages/code-editor/src/testing/codeSnippets.ts index 7e7ece91dd..7590b4f6c0 100644 --- a/packages/code-editor/src/testing/codeSnippets.ts +++ b/packages/code-editor/src/testing/codeSnippets.ts @@ -231,7 +231,7 @@ const AlienContactForm: React.FC = () => { return (
-

🌌 Intergalactic Contact Protocol

+

Intergalactic Contact Protocol