diff --git a/.changeset/yummy-jobs-wear.md b/.changeset/yummy-jobs-wear.md new file mode 100644 index 0000000000..405ba1d97b --- /dev/null +++ b/.changeset/yummy-jobs-wear.md @@ -0,0 +1,7 @@ +--- +'@leafygreen-ui/code-editor': minor +--- + +- Adds a custom search panel to the `CodeEditor` component. Contains all of the same functionality that was in the built in panel but matches the LG design language. +- Adds prop to allow consumers to enable/disable the search panel. + diff --git a/packages/code-editor/README.md b/packages/code-editor/README.md index 86d71f0db6..5d15be28aa 100644 --- a/packages/code-editor/README.md +++ b/packages/code-editor/README.md @@ -57,6 +57,7 @@ console.log(greet('MongoDB user'));`; | `enableCodeFolding` _(optional)_ | Enables code folding arrows in the gutter. | `boolean` | `undefined` | | `enableLineNumbers` _(optional)_ | Enables line numbers in the editor’s gutter. | `boolean` | `true` | | `enableLineWrapping` _(optional)_ | Enables line wrapping when the text exceeds the editor’s width. | `boolean` | `true` | +| `enableSearchPanel` \_(optional)) | Enables the find and replace search panel in the editor. | `boolean` | `true` | | `extensions` _(optional)_ | Additional CodeMirror extensions to apply to the editor. These will be applied with high precendence, meaning they can override extensions applied through built in props. See the [CodeMirror v6 System Guide](https://codemirror.net/docs/guide/) for more information. | `Array` | `[]` | | `forceParsing` _(optional)_ | _**This should be used with caution as it can significantly impact performance!**_

Forces the parsing of the complete document, even parts not currently visible.

By default, the editor optimizes performance by only parsing the code that is visible on the screen, which is especially beneficial when dealing with large amounts of code. Enabling this option overrides this behavior and forces the parsing of all code, visible or not. This should generally be reserved for exceptional circumstances. | `boolean` | `false` | | `height` _(optional)_ | Sets the editor's height. If not set, the editor will automatically adjust its height based on the content. | `string` | `undefined` | diff --git a/packages/code-editor/package.json b/packages/code-editor/package.json index f15910821e..bad379d390 100644 --- a/packages/code-editor/package.json +++ b/packages/code-editor/package.json @@ -48,15 +48,18 @@ "@leafygreen-ui/a11y": "workspace:^", "@leafygreen-ui/badge": "workspace:^", "@leafygreen-ui/button": "workspace:^", + "@leafygreen-ui/checkbox": "workspace:^", "@leafygreen-ui/emotion": "workspace:^", "@leafygreen-ui/hooks": "workspace:^", "@leafygreen-ui/icon": "workspace:^", "@leafygreen-ui/icon-button": "workspace:^", + "@leafygreen-ui/input-option": "workspace:^", "@leafygreen-ui/leafygreen-provider": "workspace:^", "@leafygreen-ui/lib": "workspace:^", "@leafygreen-ui/menu": "workspace:^", "@leafygreen-ui/modal": "workspace:^", "@leafygreen-ui/palette": "workspace:^", + "@leafygreen-ui/text-input": "workspace:^", "@leafygreen-ui/tokens": "workspace:^", "@leafygreen-ui/tooltip": "workspace:^", "@leafygreen-ui/typography": "workspace:^", diff --git a/packages/code-editor/src/CodeEditor.stories.tsx b/packages/code-editor/src/CodeEditor.stories.tsx index 8ac682d8a9..77df6c09d6 100644 --- a/packages/code-editor/src/CodeEditor.stories.tsx +++ b/packages/code-editor/src/CodeEditor.stories.tsx @@ -76,6 +76,7 @@ const meta: StoryMetaType = { enableCodeFolding: true, enableLineNumbers: true, enableLineWrapping: true, + enableSearchPanel: true, baseFontSize: BaseFontSize.Body1, forceParsing: false, placeholder: 'Type your code here...', @@ -113,6 +114,9 @@ const meta: StoryMetaType = { enableLineNumbers: { control: { type: 'boolean' }, }, + enableSearchPanel: { + control: { type: 'boolean' }, + }, enableLineWrapping: { control: { type: 'boolean' }, }, diff --git a/packages/code-editor/src/CodeEditor/CodeEditor.spec.tsx b/packages/code-editor/src/CodeEditor/CodeEditor.spec.tsx index e87b44260e..6072ae8fad 100644 --- a/packages/code-editor/src/CodeEditor/CodeEditor.spec.tsx +++ b/packages/code-editor/src/CodeEditor/CodeEditor.spec.tsx @@ -82,6 +82,22 @@ if (!global.document.createRange) { }); } +// 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: () => ({}), + }); +} + // Mock console methods to suppress expected warnings const originalConsoleWarn = console.warn; const originalConsoleError = console.error; @@ -139,7 +155,18 @@ jest.mock('@codemirror/language', () => { describe('packages/code-editor', () => { test('Renders default value in editor', async () => { - const { editor, container } = renderCodeEditor({ defaultValue: 'content' }); + const { editor, container } = renderCodeEditor({ + defaultValue: 'content', + }); + await editor.waitForEditorView(); + + expect(container).toHaveTextContent('content'); + }); + + test('Renders default value in editor with search disabled', async () => { + const { editor, container } = renderCodeEditor({ + defaultValue: 'content', + }); await editor.waitForEditorView(); expect(container).toHaveTextContent('content'); @@ -811,43 +838,6 @@ describe('packages/code-editor', () => { }); }); - test('Pressing CMD+F brings up the search menu', async () => { - const { editor, container } = renderCodeEditor({ - defaultValue: 'console.log("hello world");\nconsole.log("test");', - }); - - await editor.waitForEditorView(); - - // Focus the editor first - const contentElement = editor.getBySelector(CodeEditorSelectors.Content); - userEvent.click(contentElement); - - // Verify editor is focused - await waitFor(() => { - expect(container.querySelector('.cm-focused')).toBeInTheDocument(); - }); - - // Press Ctrl+F to open search (works on most platforms) - userEvent.keyboard('{Control>}f{/Control}'); - - // Check if the search panel appears - await waitFor(() => { - // CodeMirror 6 search creates a panel with specific classes - const searchPanel = container.querySelector( - CodeEditorSelectors.SearchPanel, - ); - expect(searchPanel).toBeInTheDocument(); - }); - - // Verify search input field is present and can be typed in - await waitFor(() => { - const searchInput = container.querySelector( - CodeEditorSelectors.SearchInput, - ); - expect(searchInput).toBeInTheDocument(); - }); - }); - test('Pressing TAB enters correct tab', async () => { const { editor } = renderCodeEditor({ defaultValue: 'console.log("test");', @@ -938,4 +928,407 @@ describe('packages/code-editor', () => { ).toBeInTheDocument(); }); }); + + 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('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/CodeEditor/CodeEditor.tsx b/packages/code-editor/src/CodeEditor/CodeEditor.tsx index 868cbfbd8c..a7e54eb88c 100644 --- a/packages/code-editor/src/CodeEditor/CodeEditor.tsx +++ b/packages/code-editor/src/CodeEditor/CodeEditor.tsx @@ -6,6 +6,7 @@ import React, { useRef, useState, } from 'react'; +import ReactDOM from 'react-dom'; import { type EditorView, type ViewUpdate } from '@codemirror/view'; import { useDarkMode } from '@leafygreen-ui/leafygreen-provider'; @@ -16,6 +17,7 @@ import { CodeEditorContextMenu } from '../CodeEditorContextMenu'; import { CodeEditorCopyButton } from '../CodeEditorCopyButton'; import { CopyButtonVariant } from '../CodeEditorCopyButton/CodeEditorCopyButton.types'; import { Panel as CodeEditorPanel } from '../Panel'; +import { SearchPanel } from '../SearchPanel'; import { getLgIds } from '../utils'; import { useModules } from './hooks/useModules'; @@ -53,6 +55,7 @@ const BaseCodeEditor = forwardRef( enableCodeFolding, enableLineNumbers, enableLineWrapping, + enableSearchPanel = true, extensions: consumerExtensions = [], forceParsing: forceParsingProp = false, height, @@ -250,13 +253,7 @@ const BaseCodeEditor = forwardRef( const searchModule = modules?.['@codemirror/search']; const Prec = modules?.['@codemirror/state']?.Prec; - if ( - !editorContainerRef?.current || - !EditorView || - !Prec || - !commands || - !searchModule - ) { + if (!editorContainerRef?.current || !EditorView || !Prec || !commands) { return; } @@ -271,7 +268,71 @@ const BaseCodeEditor = forwardRef( ), commands.history(), - searchModule.search(), + + enableSearchPanel && searchModule + ? searchModule.search({ + createPanel: view => { + const dom = document.createElement('div'); + // Your styles remain the same + dom.style.position = 'absolute'; + dom.style.top = '0'; + dom.style.right = '0'; + dom.style.left = '0'; + dom.style.display = 'flex'; + dom.style.justifyContent = 'flex-end'; + + const isReact17 = React.version.startsWith('17'); + const searchPanelElement = ( + + ); + + /** + * This conditional logic is crucial for ensuring the component uses the best rendering + * API for the environment it's in. + * + * While `ReactDOM.render` works in both React 17 and 18, using it in a React 18 + * application is highly discouraged because it forces the app into a legacy, + * synchronous mode. This disables all of React 18's concurrent features, such as + * automatic batching and transitions, sacrificing performance and responsiveness. + * + * By checking the version, we can: + * 1. Use the modern `createRoot` API in React 18 to opt-in to all its benefits. + * 2. Provide a backward-compatible fallback with `ReactDOM.render` for React 17. + * + * We disable the `react/no-deprecated` ESLint rule for the React 17 path because + * we are using these functions intentionally. + */ + if (isReact17) { + // --- React 17 Fallback Path --- + // eslint-disable-next-line react/no-deprecated + ReactDOM.render(searchPanelElement, dom); + + return { + dom, + top: true, + // eslint-disable-next-line react/no-deprecated + unmount: () => ReactDOM.unmountComponentAtNode(dom), + }; + } else { + // --- React 18+ Path --- + const { createRoot } = require('react-dom/client'); + const root = createRoot(dom); + root.render(searchPanelElement); + + return { + dom, + top: true, + unmount: () => root.unmount(), + }; + } + }, + }) + : [], EditorView.EditorView.updateListener.of((update: ViewUpdate) => { if (isControlled && update.docChanged) { @@ -298,7 +359,9 @@ const BaseCodeEditor = forwardRef( key: 'Shift-Tab', run: commands.indentLess, }, - ...searchModule.searchKeymap, + ...(enableSearchPanel && searchModule + ? searchModule.searchKeymap + : []), ...commands.defaultKeymap, ...commands.historyKeymap, ]), @@ -332,6 +395,10 @@ const BaseCodeEditor = forwardRef( customExtensions, forceParsingProp, getContents, + enableSearchPanel, + props.darkMode, + props.baseFontSize, + panel, ]); useImperativeHandle(forwardedRef, () => ({ diff --git a/packages/code-editor/src/CodeEditor/CodeEditor.types.ts b/packages/code-editor/src/CodeEditor/CodeEditor.types.ts index 4c13844254..863228cb3e 100644 --- a/packages/code-editor/src/CodeEditor/CodeEditor.types.ts +++ b/packages/code-editor/src/CodeEditor/CodeEditor.types.ts @@ -91,12 +91,15 @@ export const CodeEditorSelectors = { GutterElement: '.cm-gutterElement', Gutters: '.cm-gutters', HyperLink: '.cm-hyper-link-icon', + InnerEditor: '.cm-scroller', Line: '.cm-line', LineNumbers: '.cm-lineNumbers', LineWrapping: '.cm-lineWrapping', SearchInput: 'input[type="text"], .cm-textfield, input[placeholder*="search" i]', - SearchPanel: '.cm-search, .cm-panel', + SearchPanel: '.cm-panel', + SearchPanelContainer: '.cm-panels', + SearchPanelContainerTop: '.cm-panels-top', SelectionBackground: '.cm-selectionBackground', Tooltip: '.cm-tooltip', } as const; @@ -196,6 +199,11 @@ type BaseCodeEditorProps = DarkModeProps & */ enableLineWrapping?: boolean; + /** + * Enables the search panel in the editor. + */ + enableSearchPanel?: boolean; + /** * Additional CodeMirror extensions to apply to the editor. These will be applied * with high precendence, meaning they can override extensions applied through diff --git a/packages/code-editor/src/CodeEditor/hooks/extensions/useThemeExtension.ts b/packages/code-editor/src/CodeEditor/hooks/extensions/useThemeExtension.ts index ad111eeaae..843f181c52 100644 --- a/packages/code-editor/src/CodeEditor/hooks/extensions/useThemeExtension.ts +++ b/packages/code-editor/src/CodeEditor/hooks/extensions/useThemeExtension.ts @@ -77,8 +77,6 @@ export function useThemeExtension({ borderTopLeftRadius: hasPanel ? 0 : `${borderRadius[300]}px`, borderTopRightRadius: hasPanel ? 0 : `${borderRadius[300]}px`, color: color[theme].text[Variant.Primary][InteractionState.Default], - paddingTop: `${spacing[200]}px`, - paddingBottom: `${spacing[200]}px`, }, [`&${CodeEditorSelectors.Focused}`]: { @@ -87,6 +85,11 @@ export function useThemeExtension({ ${color[theme].border[Variant.Secondary][InteractionState.Default]}`, }, + [CodeEditorSelectors.InnerEditor]: { + paddingTop: `${spacing[200]}px`, + paddingBottom: `${spacing[200]}px`, + }, + [CodeEditorSelectors.Content]: { fontFamily: fontFamilies.code, fontSize: `${fontSize}px`, @@ -140,6 +143,14 @@ export function useThemeExtension({ [CodeEditorSelectors.DiagnosticInfo]: { border: 'none', }, + + [CodeEditorSelectors.SearchPanelContainer]: { + backgroundColor: 'transparent', + }, + + [CodeEditorSelectors.SearchPanelContainerTop]: { + border: 'none', + }, }, { dark: theme === Theme.Dark }, ); diff --git a/packages/code-editor/src/CodeEditor/hooks/useModuleLoaders.spec.ts b/packages/code-editor/src/CodeEditor/hooks/useModuleLoaders.spec.ts index e916991eb8..6c0dab1f3a 100644 --- a/packages/code-editor/src/CodeEditor/hooks/useModuleLoaders.spec.ts +++ b/packages/code-editor/src/CodeEditor/hooks/useModuleLoaders.spec.ts @@ -29,6 +29,17 @@ describe('useModuleLoaders', () => { expect(typeof loaders['@codemirror/commands']).toBe('function'); }); + test('includes search module when enableSearchPanel is true', () => { + const props: CodeEditorProps = { + ...baseProps, + enableSearchPanel: true, + }; + + const { result } = renderHook(() => useModuleLoaders(props)); + + expect(result.current).toHaveProperty('@codemirror/search'); + }); + test('includes hyperlink module when enableClickableUrls is true', () => { const props: CodeEditorProps = { ...baseProps, @@ -95,7 +106,7 @@ describe('useModuleLoaders', () => { { line: 1, column: 1, - content: 'Error message', + messages: ['Error message'], severity: 'error', length: 0, }, @@ -359,7 +370,7 @@ describe('useModuleLoaders', () => { { line: 1, column: 1, - content: 'Error', + messages: ['Error'], severity: 'error', length: 0, }, diff --git a/packages/code-editor/src/CodeEditor/hooks/useModuleLoaders.ts b/packages/code-editor/src/CodeEditor/hooks/useModuleLoaders.ts index 8d82230358..8d632e4a50 100644 --- a/packages/code-editor/src/CodeEditor/hooks/useModuleLoaders.ts +++ b/packages/code-editor/src/CodeEditor/hooks/useModuleLoaders.ts @@ -54,6 +54,7 @@ const importLangRust = () => import('@codemirror/lang-rust'); export const useModuleLoaders = ({ enableClickableUrls, enableCodeFolding, + enableSearchPanel, forceParsing, indentUnit, tooltips, @@ -70,9 +71,12 @@ export const useModuleLoaders = ({ '@codemirror/view': importCodeMirrorView, '@codemirror/state': importCodeMirrorState, '@codemirror/commands': importCodeMirrorCommands, - '@codemirror/search': importCodeMirrorSearch, }; + if (enableSearchPanel) { + neededLoaders['@codemirror/search'] = importCodeMirrorSearch; + } + if (enableClickableUrls) { neededLoaders['@uiw/codemirror-extensions-hyper-link'] = importHyperLink; } @@ -147,6 +151,7 @@ export const useModuleLoaders = ({ }, [ enableClickableUrls, enableCodeFolding, + enableSearchPanel, forceParsing, indentUnit, tooltips, diff --git a/packages/code-editor/src/CodeEditor/hooks/useModules.spec.ts b/packages/code-editor/src/CodeEditor/hooks/useModules.spec.ts index 91e5330bf1..b5a532de5f 100644 --- a/packages/code-editor/src/CodeEditor/hooks/useModules.spec.ts +++ b/packages/code-editor/src/CodeEditor/hooks/useModules.spec.ts @@ -53,7 +53,6 @@ describe('useModules', () => { '@codemirror/view', '@codemirror/state', '@codemirror/commands', - '@codemirror/search', ]; expect(Object.keys(result.current.modules).sort()).toEqual( diff --git a/packages/code-editor/src/SearchPanel/SearchPanel.styles.ts b/packages/code-editor/src/SearchPanel/SearchPanel.styles.ts new file mode 100644 index 0000000000..a52465d7da --- /dev/null +++ b/packages/code-editor/src/SearchPanel/SearchPanel.styles.ts @@ -0,0 +1,169 @@ +import { css, cx } from '@leafygreen-ui/emotion'; +import { Theme } from '@leafygreen-ui/lib'; +import { + BaseFontSize, + borderRadius, + InteractionState, + shadow, + spacing, + transitionDuration, + Variant, +} from '@leafygreen-ui/tokens'; +import { color } from '@leafygreen-ui/tokens'; + +const CONTAINER_MAX_WIDTH = 500; +const SECTION_HEIGHT = 52; +const INPUT_WIDTH = 240; +const INPUT_MIN_WIDTH = 120; + +const getBaseContainerStyles = ( + theme: Theme, + baseFontSize: BaseFontSize, +) => css` + background-color: ${color[theme].background[Variant.Secondary][ + InteractionState.Default + ]}; + font-size: ${baseFontSize}px; + border-bottom-left-radius: ${borderRadius[150]}px; + border-bottom-right-radius: ${borderRadius[150]}px; + width: 100%; + max-width: ${CONTAINER_MAX_WIDTH}px; + position: relative; + display: grid; + grid-template-rows: ${SECTION_HEIGHT}px 0fr; + transition: grid-template-rows ${transitionDuration.slower}ms ease-in-out; + z-index: 1; + + /** Add a shadow to the container while clipping the right edge*/ + &::after { + content: ''; + + /** Position the pseudo-element to match the parent's size and location */ + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + + /** Apply the shadow to the pseudo-element */ + box-shadow: ${shadow[theme][100]}; + + /** Negative values expand the clipping area outward, revealing the shadow. */ + /** A zero value clips the shadow exactly at the element's edge. */ + clip-path: inset(-20px 0px -20px -20px); + + /** Match the parent's border-radius for consistency */ + border-radius: inherit; + + /** Places the pseudo-element behind the parent element */ + z-index: -1; + } +`; + +const openContainerStyles = css` + grid-template-rows: ${SECTION_HEIGHT}px 1fr; +`; + +export const getContainerStyles = ({ + theme, + isOpen, + baseFontSize, + hasPanel, +}: { + theme: Theme; + isOpen: boolean; + baseFontSize: BaseFontSize; + hasPanel: boolean; +}) => + cx(getBaseContainerStyles(theme, baseFontSize), { + [openContainerStyles]: isOpen, + [css` + border-top-right-radius: ${borderRadius[300]}px; + `]: !hasPanel, + }); + +export const findSectionStyles = css` + display: flex; + align-items: center; + padding: ${spacing[200]}px ${spacing[300]}px; + height: 100%; +`; + +export const replaceSectionStyles = css` + min-height: 0; + overflow: hidden; +`; + +/** + * Inner section used for padding and border so that the outer section can + * fully close to 0px when set to 0fr. + */ +export const getReplaceInnerSectionStyles = (theme: Theme) => css` + display: flex; + align-items: center; + padding: 8px 10px 8px 44px; + border-top: 1px solid + ${color[theme].border[Variant.Secondary][InteractionState.Default]}; +`; + +export const toggleButtonStyles = css` + margin-right: ${spacing[100]}px; +`; + +const toggleIconStyles = css` + transform: rotate(-180deg); + transition: transform ${transitionDuration.slower}ms ease-in-out; +`; + +const openToggleIconStyles = css` + transform: rotate(0deg); +`; + +export const getToggleIconStyles = (isOpen: boolean) => + cx(toggleIconStyles, { + [openToggleIconStyles]: isOpen, + }); + +export const findInputContainerStyles = css` + position: relative; + flex: 1 1 ${INPUT_WIDTH}px; + min-width: ${INPUT_MIN_WIDTH}px; + max-width: ${INPUT_WIDTH}px; + margin-right: ${spacing[100]}px; + + & input { + padding-right: ${spacing[1200]}px; + } +`; + +export const allButtonStyles = css` + margin-left: ${spacing[100]}px; +`; + +export const closeButtonStyles = css` + margin-left: auto; +`; + +export const findOptionsContainerStyles = css` + position: absolute; + right: ${spacing[100]}px; + top: ${spacing[100]}px; + display: flex; + align-items: center; +`; + +export const replaceInputContainerStyles = css` + position: relative; + flex: 1 1 ${INPUT_WIDTH}px; + min-width: 100px; + max-width: ${INPUT_WIDTH}px; + width: 100%; +`; + +export const replaceButtonStyles = css` + margin-left: ${spacing[100]}px; +`; + +export const findInputStyles = css` + width: 100%; +`; diff --git a/packages/code-editor/src/SearchPanel/SearchPanel.tsx b/packages/code-editor/src/SearchPanel/SearchPanel.tsx new file mode 100644 index 0000000000..935eb17a23 --- /dev/null +++ b/packages/code-editor/src/SearchPanel/SearchPanel.tsx @@ -0,0 +1,415 @@ +import React, { + ChangeEvent, + KeyboardEvent, + MouseEvent, + useCallback, + useEffect, + useRef, + useState, +} from 'react'; +import { + closeSearchPanel, + findNext, + findPrevious, + replaceAll, + replaceNext, + SearchQuery, + selectMatches, + setSearchQuery, +} from '@codemirror/search'; + +import { Button } from '@leafygreen-ui/button'; +import { Checkbox } from '@leafygreen-ui/checkbox'; +import { Icon } from '@leafygreen-ui/icon'; +import { IconButton } from '@leafygreen-ui/icon-button'; +import { InputOption } from '@leafygreen-ui/input-option'; +import { useDarkMode } from '@leafygreen-ui/leafygreen-provider'; +import { Menu, MenuVariant } from '@leafygreen-ui/menu'; +import { TextInput } from '@leafygreen-ui/text-input'; +import { Body, useUpdatedBaseFontSize } from '@leafygreen-ui/typography'; + +import { + allButtonStyles, + closeButtonStyles, + findInputContainerStyles, + findInputStyles, + findOptionsContainerStyles, + findSectionStyles, + getContainerStyles, + getReplaceInnerSectionStyles, + getToggleIconStyles, + replaceButtonStyles, + replaceInputContainerStyles, + replaceSectionStyles, + toggleButtonStyles, +} from './SearchPanel.styles'; +import { SearchPanelProps } from './SearchPanel.types'; + +export function SearchPanel({ + view, + darkMode, + baseFontSize: baseFontSizeProp, + hasPanel, +}: SearchPanelProps) { + const [isOpen, setIsOpen] = useState(false); + const [searchString, setSearchString] = useState(''); + const [replaceString, setReplaceString] = useState(''); + const [isCaseSensitive, setIsCaseSensitive] = useState(false); + const [isWholeWord, setIsWholeWord] = useState(false); + const [isRegex, setIsRegex] = useState(false); + const [query, setQuery] = useState( + new SearchQuery({ + search: searchString, + caseSensitive: isCaseSensitive, + regexp: isRegex, + wholeWord: isWholeWord, + replace: replaceString, + }), + ); + const [findCount, setFindCount] = useState(0); + const { theme } = useDarkMode(darkMode); + const baseFontSize = useUpdatedBaseFontSize(); + const [selectedIndex, setSelectedIndex] = useState(null); + const findOptions = { + isCaseSensitive: 'Match case', + isRegex: 'Regexp', + isWholeWord: 'By word', + } as const; + const [highlightedOption, setHighlightedOption] = useState( + null, + ); + + const isInitialRender = useRef(true); + const inputRef = useRef(null); + + const updateSelectedIndex = useCallback(() => { + const cursor = query.getCursor(view.state.doc); + const selection = view.state.selection.main; + let index = 1; + let result = cursor.next(); + + while (!result.done) { + if ( + result.value.from === selection.from && + result.value.to === selection.to + ) { + setSelectedIndex(index); + return; + } + index++; + result = cursor.next(); + } + setSelectedIndex(null); + }, [query, view]); + + const updateFindCount = useCallback( + (searchQuery: SearchQuery) => { + const cursor = searchQuery.getCursor(view.state.doc); + let count = 0; + let result = cursor.next(); + + while (!result.done) { + count++; + result = cursor.next(); + } + setFindCount(count); + }, + [view], + ); + + useEffect(() => { + const newQuery = new SearchQuery({ + search: searchString, + caseSensitive: isCaseSensitive, + regexp: isRegex, + wholeWord: isWholeWord, + replace: replaceString, + }); + + setQuery(newQuery); + + if (isInitialRender.current) { + /** + * This effect synchronizes the React component's state with the CodeMirror search extension. + * A race condition occurs on the initial render because this React component is created + * and rendered *during* an ongoing CodeMirror state update (a "transaction"). + * + * If we dispatch our own transaction synchronously within this effect, CodeMirror could throw + * an error because it doesn't allow nested transactions. + * + * To solve this, we use `setTimeout(..., 0)` to defer the initial dispatch. This pushes our + * update to the end of the browser's event queue, ensuring it runs immediately after + * CodeMirror's current transaction is complete. Subsequent updates are dispatched + * synchronously for immediate UI feedback. + */ + const timeoutId = setTimeout(() => { + view.dispatch({ effects: setSearchQuery.of(newQuery) }); + }, 0); + isInitialRender.current = false; + return () => clearTimeout(timeoutId); // Cleanup the timeout on unmount + } else { + // On all subsequent updates (e.g., user typing), dispatch immediately. + view.dispatch({ effects: setSearchQuery.of(newQuery) }); + } + + updateFindCount(newQuery); + }, [ + replaceString, + searchString, + isCaseSensitive, + isRegex, + isWholeWord, + view, + updateFindCount, + ]); + + /** + * This effect manually focuses the search input when the panel first opens. The standard + * `autoFocus` prop is unreliable here due to a race condition with CodeMirror's own + * focus management. + * + * When the panel is created, CodeMirror's logic often runs after React renders and may shift + * focus back to the main editor, "stealing" it from our input. By deferring the `focus()` call + * with `setTimeout(..., 0)`, we ensure our command runs last, winning the focus race. + */ + useEffect(() => { + const timeoutId = setTimeout(() => { + inputRef.current?.focus(); + }, 0); + + return () => clearTimeout(timeoutId); + }, []); // An empty dependency array ensures this runs only once when the component mounts + + const handleToggleButtonClick = useCallback( + (_e: MouseEvent) => { + setIsOpen(currState => !currState); + }, + [], + ); + + const handleCloseButtonClick = useCallback( + (_e: MouseEvent) => { + closeSearchPanel(view); + }, + [view], + ); + + const handleSearchQueryChange = useCallback( + (_e: ChangeEvent) => { + setSearchString(_e.target.value); + }, + [], + ); + + const handleReplaceQueryChange = useCallback( + (_e: ChangeEvent) => { + setReplaceString(_e.target.value); + }, + [], + ); + + const handleFindNext = useCallback(() => { + findNext(view); + updateSelectedIndex(); + }, [view, updateSelectedIndex]); + + const handleFindPrevious = useCallback(() => { + findPrevious(view); + updateSelectedIndex(); + }, [view, updateSelectedIndex]); + + const handleFindAll = useCallback(() => { + selectMatches(view); + updateSelectedIndex(); + }, [view, updateSelectedIndex]); + + const handleReplace = useCallback(() => { + replaceNext(view); + updateSelectedIndex(); + updateFindCount(query); + }, [view, updateSelectedIndex, updateFindCount, query]); + + const handleReplaceAll = useCallback(() => { + replaceAll(view); + updateSelectedIndex(); + updateFindCount(query); + }, [view, updateSelectedIndex, updateFindCount, query]); + + const handleFindInputKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + if (e.shiftKey) { + handleFindPrevious(); + } else { + handleFindNext(); + } + } else if (e.key === 'Escape') { + e.preventDefault(); + closeSearchPanel(view); + } + }, + [handleFindNext, handleFindPrevious, view], + ); + + const handleReplaceInputKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleReplace(); + } else if (e.key === 'Escape') { + e.preventDefault(); + closeSearchPanel(view); + } + }, + [handleReplace, view], + ); + + return ( +
+
+ + + +
+ +
+ {searchString && ( + + {selectedIndex ?? '?'}/{findCount} + + )} + + + + } + renderDarkMenu={false} + variant={MenuVariant.Compact} + > + {Object.entries(findOptions).map(([key, value]) => ( + + { + switch (key) { + case 'isCaseSensitive': + setIsCaseSensitive(!isCaseSensitive); + break; + case 'isRegex': + setIsRegex(!isRegex); + break; + case 'isWholeWord': + setIsWholeWord(!isWholeWord); + break; + } + setHighlightedOption(key); + }} + /> + + ))} + +
+
+ + + + + + + + + + +
+
+
+ + + +
+
+
+ ); +} diff --git a/packages/code-editor/src/SearchPanel/SearchPanel.types.ts b/packages/code-editor/src/SearchPanel/SearchPanel.types.ts new file mode 100644 index 0000000000..186b3937fc --- /dev/null +++ b/packages/code-editor/src/SearchPanel/SearchPanel.types.ts @@ -0,0 +1,23 @@ +import { DarkModeProps } from '@leafygreen-ui/lib'; +import { type BaseFontSize } from '@leafygreen-ui/tokens'; + +import { CodeMirrorView } from '../CodeEditor'; + +export interface SearchPanelProps extends DarkModeProps { + /** + * Font size of text in the editor. + * + * @default 13 + */ + baseFontSize?: BaseFontSize; + + /** + * The CodeMirror view instance. + */ + view: CodeMirrorView; + + /** + * Whether the CodeEditor is rendered within a panel component as well. + */ + hasPanel: boolean; +} diff --git a/packages/code-editor/src/SearchPanel/index.ts b/packages/code-editor/src/SearchPanel/index.ts new file mode 100644 index 0000000000..49779c5d80 --- /dev/null +++ b/packages/code-editor/src/SearchPanel/index.ts @@ -0,0 +1,2 @@ +export { SearchPanel } from './SearchPanel'; +export { type SearchPanelProps } from './SearchPanel.types'; diff --git a/packages/code-editor/tsconfig.json b/packages/code-editor/tsconfig.json index 3ca0966d1f..9cf4002127 100644 --- a/packages/code-editor/tsconfig.json +++ b/packages/code-editor/tsconfig.json @@ -18,12 +18,18 @@ { "path": "../button" }, + { + "path": "../checkbox" + }, { "path": "../emotion" }, { "path": "../hooks" }, + { + "path": "../input-option" + }, { "path": "../lib" }, @@ -39,6 +45,9 @@ { "path": "../tokens" }, + { + "path": "../text-input" + }, { "path": "../leafygreen-provider" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d4cb728d57..003d3708e8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1296,6 +1296,9 @@ importers: '@leafygreen-ui/button': specifier: workspace:^ version: link:../button + '@leafygreen-ui/checkbox': + specifier: workspace:^ + version: link:../checkbox '@leafygreen-ui/emotion': specifier: workspace:^ version: link:../emotion @@ -1308,6 +1311,9 @@ importers: '@leafygreen-ui/icon-button': specifier: workspace:^ version: link:../icon-button + '@leafygreen-ui/input-option': + specifier: workspace:^ + version: link:../input-option '@leafygreen-ui/leafygreen-provider': specifier: workspace:^ version: link:../leafygreen-provider @@ -1323,6 +1329,9 @@ importers: '@leafygreen-ui/palette': specifier: workspace:^ version: link:../palette + '@leafygreen-ui/text-input': + specifier: workspace:^ + version: link:../text-input '@leafygreen-ui/tokens': specifier: workspace:^ version: link:../tokens