From 3c7976e9955d76b2ffc219670c635e179334afa6 Mon Sep 17 00:00:00 2001 From: Terrence Keane Date: Tue, 7 Oct 2025 10:11:57 -0400 Subject: [PATCH 01/59] Vertically align panel buttons --- packages/code-editor/src/Panel/Panel.styles.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/code-editor/src/Panel/Panel.styles.ts b/packages/code-editor/src/Panel/Panel.styles.ts index 5f03404d80..79566e7b1c 100644 --- a/packages/code-editor/src/Panel/Panel.styles.ts +++ b/packages/code-editor/src/Panel/Panel.styles.ts @@ -50,6 +50,8 @@ export const getPanelInnerContentStyles = () => { export const getPanelButtonsStyles = () => { return css` grid-area: buttons; + display: flex; + justify-content: center; `; }; From 30d31825f0a80d86ed9aee49af0b812980856372 Mon Sep 17 00:00:00 2001 From: Terrence Keane Date: Tue, 7 Oct 2025 10:12:08 -0400 Subject: [PATCH 02/59] Fix persistent copy button styles --- .../CodeEditorCopyButton.styles.ts | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) 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, From 340522db18c022b0412ce8ee2eb5778997e6ec0b Mon Sep 17 00:00:00 2001 From: Terrence Keane Date: Tue, 7 Oct 2025 10:23:47 -0400 Subject: [PATCH 03/59] More complete live example --- .../code-editor/src/CodeEditor.stories.tsx | 68 +++++++++++++++++-- 1 file changed, 64 insertions(+), 4 deletions(-) diff --git a/packages/code-editor/src/CodeEditor.stories.tsx b/packages/code-editor/src/CodeEditor.stories.tsx index 77df6c09d6..fcfb09623b 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: ( Date: Tue, 7 Oct 2025 10:27:40 -0400 Subject: [PATCH 04/59] Decrease tooltip delay --- .../hooks/extensions/useTooltipExtension.ts | 77 ++++++++++--------- 1 file changed, 41 insertions(+), 36 deletions(-) diff --git a/packages/code-editor/src/CodeEditor/hooks/extensions/useTooltipExtension.ts b/packages/code-editor/src/CodeEditor/hooks/extensions/useTooltipExtension.ts index a84a281de3..fcbbbff605 100644 --- a/packages/code-editor/src/CodeEditor/hooks/extensions/useTooltipExtension.ts +++ b/packages/code-editor/src/CodeEditor/hooks/extensions/useTooltipExtension.ts @@ -47,44 +47,49 @@ 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; + }, + { + delay: 100, + }, + ); }, }); } From 90340a5cffbb298e03287a23a204b880022b25ec Mon Sep 17 00:00:00 2001 From: Terrence Keane Date: Tue, 7 Oct 2025 10:29:19 -0400 Subject: [PATCH 05/59] Add comment on decrease in tooltip speed --- .../src/CodeEditor/hooks/extensions/useTooltipExtension.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/code-editor/src/CodeEditor/hooks/extensions/useTooltipExtension.ts b/packages/code-editor/src/CodeEditor/hooks/extensions/useTooltipExtension.ts index fcbbbff605..12e1dfc839 100644 --- a/packages/code-editor/src/CodeEditor/hooks/extensions/useTooltipExtension.ts +++ b/packages/code-editor/src/CodeEditor/hooks/extensions/useTooltipExtension.ts @@ -87,6 +87,10 @@ export function useTooltipExtension({ return diagnostics; }, { + /** + * Decreasing but if consumers decide to use this for live linting, we might want to revert this so it + * show errors too fast. + */ delay: 100, }, ); From 0aaa0a018c19b7571a84cd55836c4ad7ab1c2b17 Mon Sep 17 00:00:00 2001 From: Terrence Keane Date: Wed, 8 Oct 2025 10:56:21 -0400 Subject: [PATCH 06/59] Fix gutter padding when line numbers disabled --- .../src/CodeEditor/hooks/extensions/useThemeExtension.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/code-editor/src/CodeEditor/hooks/extensions/useThemeExtension.ts b/packages/code-editor/src/CodeEditor/hooks/extensions/useThemeExtension.ts index d1054f979e..d1b002f622 100644 --- a/packages/code-editor/src/CodeEditor/hooks/extensions/useThemeExtension.ts +++ b/packages/code-editor/src/CodeEditor/hooks/extensions/useThemeExtension.ts @@ -114,6 +114,13 @@ 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]: { From 7c2bd4214a630bd398abe8121a7049f03ebf019a Mon Sep 17 00:00:00 2001 From: Terrence Keane Date: Wed, 8 Oct 2025 11:16:28 -0400 Subject: [PATCH 07/59] Fix close chevron alignment --- .../CodeEditor/hooks/extensions/useCodeFoldingExtension.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/code-editor/src/CodeEditor/hooks/extensions/useCodeFoldingExtension.tsx b/packages/code-editor/src/CodeEditor/hooks/extensions/useCodeFoldingExtension.tsx index a02c9efc90..8fc918b70c 100644 --- a/packages/code-editor/src/CodeEditor/hooks/extensions/useCodeFoldingExtension.tsx +++ b/packages/code-editor/src/CodeEditor/hooks/extensions/useCodeFoldingExtension.tsx @@ -3,7 +3,7 @@ import { renderToString } from 'react-dom/server'; import { type EditorView } from '@codemirror/view'; import { css } from '@leafygreen-ui/emotion'; -import Icon from '@leafygreen-ui/icon'; +import { Icon } from '@leafygreen-ui/icon'; import { spacing } from '@leafygreen-ui/tokens'; import { type CodeEditorProps } from '../../CodeEditor.types'; @@ -66,7 +66,8 @@ 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 */ + margin-top: ${spacing[100] + 1}px; `} /> ), From fe622fd4dcc0279426df12fffa88c97bb7dd1b05 Mon Sep 17 00:00:00 2001 From: Terrence Keane Date: Wed, 8 Oct 2025 11:20:42 -0400 Subject: [PATCH 08/59] Remove fold placeholder color --- packages/code-editor/src/CodeEditor/CodeEditor.types.ts | 1 + .../src/CodeEditor/hooks/extensions/useThemeExtension.ts | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/packages/code-editor/src/CodeEditor/CodeEditor.types.ts b/packages/code-editor/src/CodeEditor/CodeEditor.types.ts index 4e5a471732..c5ffded90a 100644 --- a/packages/code-editor/src/CodeEditor/CodeEditor.types.ts +++ b/packages/code-editor/src/CodeEditor/CodeEditor.types.ts @@ -88,6 +88,7 @@ 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', diff --git a/packages/code-editor/src/CodeEditor/hooks/extensions/useThemeExtension.ts b/packages/code-editor/src/CodeEditor/hooks/extensions/useThemeExtension.ts index d1b002f622..d5d760776b 100644 --- a/packages/code-editor/src/CodeEditor/hooks/extensions/useThemeExtension.ts +++ b/packages/code-editor/src/CodeEditor/hooks/extensions/useThemeExtension.ts @@ -91,6 +91,10 @@ export function useThemeExtension({ paddingBottom: `${spacing[200]}px`, }, + [CodeEditorSelectors.FoldPlaceholder]: { + backgroundColor: 'transparent', + }, + [CodeEditorSelectors.Content]: { fontFamily: fontFamilies.code, fontSize: `${fontSize}px`, From d8894c4441104bd4f9fc811d4719d77f15c37a2d Mon Sep 17 00:00:00 2001 From: Terrence Keane Date: Wed, 8 Oct 2025 11:23:37 -0400 Subject: [PATCH 09/59] Remove some default imports --- packages/code-editor/src/Panel/Panel.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/code-editor/src/Panel/Panel.tsx b/packages/code-editor/src/Panel/Panel.tsx index 70dbb9c205..765194b743 100644 --- a/packages/code-editor/src/Panel/Panel.tsx +++ b/packages/code-editor/src/Panel/Panel.tsx @@ -12,11 +12,11 @@ import QuestionMarkWithCircleIcon from '@leafygreen-ui/icon/dist/QuestionMarkWit import RedoIcon from '@leafygreen-ui/icon/dist/Redo'; // @ts-ignore LG icons don't currently support TS import UndoIcon from '@leafygreen-ui/icon/dist/Undo'; -import IconButton from '@leafygreen-ui/icon-button'; +import { IconButton } from '@leafygreen-ui/icon-button'; import { useDarkMode } from '@leafygreen-ui/leafygreen-provider'; import { Menu, MenuItem, MenuVariant } from '@leafygreen-ui/menu'; -import Modal from '@leafygreen-ui/modal'; -import Tooltip from '@leafygreen-ui/tooltip'; +import { Modal } from '@leafygreen-ui/modal'; +import { Tooltip } from '@leafygreen-ui/tooltip'; import { useUpdatedBaseFontSize } from '@leafygreen-ui/typography'; import { useCodeEditorContext } from '../CodeEditor/CodeEditorContext'; From 95ee7ed11f80817f96160b4623f8e2bd96e6e5e7 Mon Sep 17 00:00:00 2001 From: Terrence Keane Date: Wed, 8 Oct 2025 11:32:51 -0400 Subject: [PATCH 10/59] Make Panel respect editor width props --- .../code-editor/src/CodeEditor/CodeEditor.tsx | 3 + .../src/CodeEditor/CodeEditorContext.tsx | 7 +- .../code-editor/src/Panel/Panel.styles.ts | 64 +++++++++++++------ packages/code-editor/src/Panel/Panel.tsx | 18 +++++- 4 files changed, 67 insertions(+), 25 deletions(-) diff --git a/packages/code-editor/src/CodeEditor/CodeEditor.tsx b/packages/code-editor/src/CodeEditor/CodeEditor.tsx index 7def42d257..d3d8d7aceb 100644 --- a/packages/code-editor/src/CodeEditor/CodeEditor.tsx +++ b/packages/code-editor/src/CodeEditor/CodeEditor.tsx @@ -367,6 +367,9 @@ const BaseCodeEditor = forwardRef( redo: handleRedo, downloadContent: handleDownloadContent, lgIds, + maxWidth, + minWidth, + width, }; return ( diff --git a/packages/code-editor/src/CodeEditor/CodeEditorContext.tsx b/packages/code-editor/src/CodeEditor/CodeEditorContext.tsx index 38797a1bdf..5790d65048 100644 --- a/packages/code-editor/src/CodeEditor/CodeEditorContext.tsx +++ b/packages/code-editor/src/CodeEditor/CodeEditorContext.tsx @@ -3,11 +3,13 @@ import React, { createContext, useContext } from 'react'; import { getLgIds, type GetLgIdsReturnType } from '../utils/getLgIds'; import { type LanguageName } from './hooks/extensions/useLanguageExtension'; +import { CodeEditorProps } from './CodeEditor.types'; /** * Internal context values provided by CodeEditor to its children (like Panel). */ -export interface CodeEditorContextValue { +export interface CodeEditorContextValue + extends Pick { /** * Function to retrieve the current editor contents. */ @@ -67,6 +69,9 @@ 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, }; const CodeEditorContext = diff --git a/packages/code-editor/src/Panel/Panel.styles.ts b/packages/code-editor/src/Panel/Panel.styles.ts index 79566e7b1c..9d3e76c7da 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, @@ -11,26 +11,48 @@ import { 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) => { diff --git a/packages/code-editor/src/Panel/Panel.tsx b/packages/code-editor/src/Panel/Panel.tsx index 765194b743..8b3c8c0e07 100644 --- a/packages/code-editor/src/Panel/Panel.tsx +++ b/packages/code-editor/src/Panel/Panel.tsx @@ -66,8 +66,17 @@ export function Panel({ const { theme } = useDarkMode(darkMode); const baseFontSize = useUpdatedBaseFontSize(); - const { getContents, formatCode, undo, redo, downloadContent, lgIds } = - useCodeEditorContext(); + const { + getContents, + formatCode, + undo, + redo, + downloadContent, + lgIds, + maxWidth, + minWidth, + width, + } = useCodeEditorContext(); const handleFormatClick = async () => { if (formatCode) { @@ -115,7 +124,10 @@ export function Panel({ return ( <> -
+
Date: Wed, 8 Oct 2025 12:01:37 -0400 Subject: [PATCH 11/59] Some more default imports --- .../src/CodeEditorCopyButton/CodeEditorCopyButton.tsx | 3 ++- .../CodeEditorCopyButtonTrigger.tsx | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/code-editor/src/CodeEditorCopyButton/CodeEditorCopyButton.tsx b/packages/code-editor/src/CodeEditorCopyButton/CodeEditorCopyButton.tsx index 7e812d23d5..2177181876 100644 --- a/packages/code-editor/src/CodeEditorCopyButton/CodeEditorCopyButton.tsx +++ b/packages/code-editor/src/CodeEditorCopyButton/CodeEditorCopyButton.tsx @@ -5,11 +5,12 @@ import { useDarkMode } from '@leafygreen-ui/leafygreen-provider'; import { keyMap } from '@leafygreen-ui/lib'; import { palette } from '@leafygreen-ui/palette'; import { color } from '@leafygreen-ui/tokens'; -import Tooltip, { +import { Align, hoverDelay, Justify, RenderMode, + Tooltip, } from '@leafygreen-ui/tooltip'; import { CopyButtonTrigger } from '../CodeEditorCopyButtonTrigger'; diff --git a/packages/code-editor/src/CodeEditorCopyButtonTrigger/CodeEditorCopyButtonTrigger.tsx b/packages/code-editor/src/CodeEditorCopyButtonTrigger/CodeEditorCopyButtonTrigger.tsx index 2011a820ac..8ff6b9f042 100644 --- a/packages/code-editor/src/CodeEditorCopyButtonTrigger/CodeEditorCopyButtonTrigger.tsx +++ b/packages/code-editor/src/CodeEditorCopyButtonTrigger/CodeEditorCopyButtonTrigger.tsx @@ -1,10 +1,10 @@ import React from 'react'; import { VisuallyHidden } from '@leafygreen-ui/a11y'; -import Button from '@leafygreen-ui/button'; +import { Button } from '@leafygreen-ui/button'; import CheckmarkIcon from '@leafygreen-ui/icon/dist/Checkmark'; import CopyIcon from '@leafygreen-ui/icon/dist/Copy'; -import IconButton from '@leafygreen-ui/icon-button'; +import { IconButton } from '@leafygreen-ui/icon-button'; import { CopyButtonVariant } from '../CodeEditorCopyButton/CodeEditorCopyButton.types'; import { COPIED_TEXT } from '../CodeEditorCopyButton/constants'; From c3467d2823b2f74a4347ec244ed7e0c9fdf375af Mon Sep 17 00:00:00 2001 From: Terrence Keane Date: Wed, 8 Oct 2025 12:04:39 -0400 Subject: [PATCH 12/59] Disable undo/redo on readonly --- packages/code-editor/src/CodeEditor/CodeEditor.tsx | 1 + .../code-editor/src/CodeEditor/CodeEditorContext.tsx | 6 +++++- packages/code-editor/src/Panel/Panel.tsx | 11 +++++++---- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/code-editor/src/CodeEditor/CodeEditor.tsx b/packages/code-editor/src/CodeEditor/CodeEditor.tsx index d3d8d7aceb..99ccbbb4c4 100644 --- a/packages/code-editor/src/CodeEditor/CodeEditor.tsx +++ b/packages/code-editor/src/CodeEditor/CodeEditor.tsx @@ -370,6 +370,7 @@ const BaseCodeEditor = forwardRef( maxWidth, minWidth, width, + readOnly, }; return ( diff --git a/packages/code-editor/src/CodeEditor/CodeEditorContext.tsx b/packages/code-editor/src/CodeEditor/CodeEditorContext.tsx index 5790d65048..f6e8260404 100644 --- a/packages/code-editor/src/CodeEditor/CodeEditorContext.tsx +++ b/packages/code-editor/src/CodeEditor/CodeEditorContext.tsx @@ -9,7 +9,10 @@ import { CodeEditorProps } from './CodeEditor.types'; * Internal context values provided by CodeEditor to its children (like Panel). */ export interface CodeEditorContextValue - extends Pick { + extends Pick< + CodeEditorProps, + 'maxWidth' | 'minWidth' | 'width' | 'readOnly' + > { /** * Function to retrieve the current editor contents. */ @@ -72,6 +75,7 @@ const defaultContextValue: CodeEditorContextValue = { maxWidth: undefined, minWidth: undefined, width: undefined, + readOnly: false, }; const CodeEditorContext = diff --git a/packages/code-editor/src/Panel/Panel.tsx b/packages/code-editor/src/Panel/Panel.tsx index 8b3c8c0e07..a8f3bb1927 100644 --- a/packages/code-editor/src/Panel/Panel.tsx +++ b/packages/code-editor/src/Panel/Panel.tsx @@ -67,14 +67,15 @@ export function Panel({ const baseFontSize = useUpdatedBaseFontSize(); const { - getContents, - formatCode, - undo, - redo, downloadContent, + formatCode, + getContents, lgIds, maxWidth, minWidth, + readOnly, + redo, + undo, width, } = useCodeEditorContext(); @@ -184,6 +185,7 @@ export function Panel({ glyph={} onClick={handleUndoClick} aria-label="Undo changes" + disabled={readOnly} > Undo @@ -191,6 +193,7 @@ export function Panel({ glyph={} onClick={handleRedoClick} aria-label="Redo changes" + disabled={readOnly} > Redo From 95746bac177fabf0245b89b19ca027df01fe45c2 Mon Sep 17 00:00:00 2001 From: Terrence Keane Date: Wed, 8 Oct 2025 14:02:40 -0400 Subject: [PATCH 13/59] Add change depth states (broken) --- .../code-editor/src/CodeEditor/CodeEditor.tsx | 23 +++++++++++++++---- .../src/CodeEditor/CodeEditorContext.tsx | 12 ++++++++++ packages/code-editor/src/Panel/Panel.tsx | 6 +++-- 3 files changed, 35 insertions(+), 6 deletions(-) diff --git a/packages/code-editor/src/CodeEditor/CodeEditor.tsx b/packages/code-editor/src/CodeEditor/CodeEditor.tsx index 99ccbbb4c4..e62c739acb 100644 --- a/packages/code-editor/src/CodeEditor/CodeEditor.tsx +++ b/packages/code-editor/src/CodeEditor/CodeEditor.tsx @@ -85,6 +85,8 @@ const BaseCodeEditor = forwardRef( const isControlled = value !== undefined; const editorContainerRef = useRef(null); const editorViewRef = useRef(null); + const [undoDepth, setUndoDepth] = useState(1); + const [redoDepth, setRedoDepth] = useState(1); const { modules, isLoading } = useModules(props); @@ -281,10 +283,21 @@ 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) { + console.log('update.docChanged'); + setUndoDepth(commands.undoDepth(state)); + setRedoDepth(commands.redoDepth(state)); + } } }), @@ -364,7 +377,9 @@ const BaseCodeEditor = forwardRef( isFormattingAvailable, language, undo: handleUndo, + undoDepth, redo: handleRedo, + redoDepth, downloadContent: handleDownloadContent, lgIds, maxWidth, diff --git a/packages/code-editor/src/CodeEditor/CodeEditorContext.tsx b/packages/code-editor/src/CodeEditor/CodeEditorContext.tsx index f6e8260404..9cde3189d2 100644 --- a/packages/code-editor/src/CodeEditor/CodeEditorContext.tsx +++ b/packages/code-editor/src/CodeEditor/CodeEditorContext.tsx @@ -58,6 +58,16 @@ 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; } // Default context value for when Panel is used standalone @@ -76,6 +86,8 @@ const defaultContextValue: CodeEditorContextValue = { minWidth: undefined, width: undefined, readOnly: false, + undoDepth: 0, + redoDepth: 0, }; const CodeEditorContext = diff --git a/packages/code-editor/src/Panel/Panel.tsx b/packages/code-editor/src/Panel/Panel.tsx index a8f3bb1927..826350ef36 100644 --- a/packages/code-editor/src/Panel/Panel.tsx +++ b/packages/code-editor/src/Panel/Panel.tsx @@ -75,7 +75,9 @@ export function Panel({ minWidth, readOnly, redo, + redoDepth, undo, + undoDepth, width, } = useCodeEditorContext(); @@ -185,7 +187,7 @@ export function Panel({ glyph={} onClick={handleUndoClick} aria-label="Undo changes" - disabled={readOnly} + disabled={readOnly || undoDepth === 0} > Undo @@ -193,7 +195,7 @@ export function Panel({ glyph={} onClick={handleRedoClick} aria-label="Redo changes" - disabled={readOnly} + disabled={readOnly || redoDepth === 0} > Redo From fa34c7904eb5e1a2ab6f1080bb8c522aee6c5405 Mon Sep 17 00:00:00 2001 From: Terrence Keane Date: Wed, 8 Oct 2025 14:52:15 -0400 Subject: [PATCH 14/59] Fix re-init editor bug on prop change --- .../code-editor/src/CodeEditor/CodeEditor.tsx | 60 ++++++++++++------- 1 file changed, 40 insertions(+), 20 deletions(-) diff --git a/packages/code-editor/src/CodeEditor/CodeEditor.tsx b/packages/code-editor/src/CodeEditor/CodeEditor.tsx index e62c739acb..2c8b496972 100644 --- a/packages/code-editor/src/CodeEditor/CodeEditor.tsx +++ b/packages/code-editor/src/CodeEditor/CodeEditor.tsx @@ -85,8 +85,8 @@ const BaseCodeEditor = forwardRef( const isControlled = value !== undefined; const editorContainerRef = useRef(null); const editorViewRef = useRef(null); - const [undoDepth, setUndoDepth] = useState(1); - const [redoDepth, setRedoDepth] = useState(1); + const [undoDepth, setUndoDepth] = useState(0); + const [redoDepth, setRedoDepth] = useState(0); const { modules, isLoading } = useModules(props); @@ -260,9 +260,6 @@ const BaseCodeEditor = forwardRef( 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) { return; @@ -270,10 +267,43 @@ const BaseCodeEditor = forwardRef( const domNode = editorContainerRef.current as HTMLElementWithCodeMirror; + // 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 + 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), ), @@ -294,7 +324,6 @@ const BaseCodeEditor = forwardRef( } if (commands && state) { - console.log('update.docChanged'); setUndoDepth(commands.undoDepth(state)); setRedoDepth(commands.redoDepth(state)); } @@ -326,7 +355,7 @@ const BaseCodeEditor = forwardRef( ]), ...customExtensions, - ], + ]), }); if (forceParsingProp) { @@ -337,19 +366,7 @@ const BaseCodeEditor = forwardRef( Language.forceParsing(editorViewRef.current, docLength, 150); } } - - return () => { - /** Delete the CodeMirror instance from the DOM node */ - delete domNode._cm; - editorViewRef.current?.destroy(); - }; }, [ - value, - modules, - controlledValue, - defaultValue, - isControlled, - onChangeProp, consumerExtensions, customExtensions, forceParsingProp, @@ -359,6 +376,9 @@ const BaseCodeEditor = forwardRef( props.baseFontSize, panel, searchPanelExtension, + isControlled, + modules, + onChangeProp, ]); useImperativeHandle(forwardedRef, () => ({ From 806b7b48a3a722ce7f8388881204ec27ba527adf Mon Sep 17 00:00:00 2001 From: Terrence Keane Date: Wed, 8 Oct 2025 15:14:07 -0400 Subject: [PATCH 15/59] Remove caret when readonly --- .../hooks/extensions/useReadOnlyExtension.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/code-editor/src/CodeEditor/hooks/extensions/useReadOnlyExtension.ts b/packages/code-editor/src/CodeEditor/hooks/extensions/useReadOnlyExtension.ts index 35f1fa2b1c..c2c0c6df93 100644 --- a/packages/code-editor/src/CodeEditor/hooks/extensions/useReadOnlyExtension.ts +++ b/packages/code-editor/src/CodeEditor/hooks/extensions/useReadOnlyExtension.ts @@ -33,8 +33,14 @@ 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 + ? [ + module.EditorState.readOnly.of(true), + EditorViewModule.EditorView.editable.of(false), + ] + : [], }); } From 8e6c0042f1ca86e05e2aa83b1bc22ec52fb7ba7e Mon Sep 17 00:00:00 2001 From: Terrence Keane Date: Tue, 21 Oct 2025 11:56:45 -0400 Subject: [PATCH 16/59] Refactor editor initialization check to remove unnecessary dependencies --- packages/code-editor/src/CodeEditor/CodeEditor.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/code-editor/src/CodeEditor/CodeEditor.tsx b/packages/code-editor/src/CodeEditor/CodeEditor.tsx index 2c8b496972..eab1c5b373 100644 --- a/packages/code-editor/src/CodeEditor/CodeEditor.tsx +++ b/packages/code-editor/src/CodeEditor/CodeEditor.tsx @@ -261,7 +261,7 @@ const BaseCodeEditor = forwardRef( useLayoutEffect(() => { const EditorView = modules?.['@codemirror/view']; - if (!editorContainerRef?.current || !EditorView || !Prec || !commands) { + if (!editorContainerRef?.current || !EditorView) { return; } From 78f92430de739b04f5c8fa0271cdb6b1250807a9 Mon Sep 17 00:00:00 2001 From: Terrence Keane Date: Tue, 21 Oct 2025 11:57:31 -0400 Subject: [PATCH 17/59] Update inert attribute handling in SearchPanel for better type compatibility --- packages/code-editor/src/SearchPanel/SearchPanel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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({
From 03de69f364228a0faf81ffdef624b5b3371c032d Mon Sep 17 00:00:00 2001 From: Terrence Keane Date: Tue, 21 Oct 2025 13:35:13 -0400 Subject: [PATCH 18/59] Add context menu interaction handling to prevent selection changes --- .../src/ContextMenu/ContextMenu.tsx | 47 +++++++++++++++++-- 1 file changed, 42 insertions(+), 5 deletions(-) diff --git a/packages/code-editor/src/ContextMenu/ContextMenu.tsx b/packages/code-editor/src/ContextMenu/ContextMenu.tsx index 55668a1d14..458e46c296 100644 --- a/packages/code-editor/src/ContextMenu/ContextMenu.tsx +++ b/packages/code-editor/src/ContextMenu/ContextMenu.tsx @@ -49,6 +49,41 @@ export const ContextMenu = ({ const containerRef = useRef(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 (
Date: Tue, 21 Oct 2025 16:14:37 -0400 Subject: [PATCH 19/59] Fix font size and dark mode --- .../code-editor/src/CodeEditor/CodeEditor.tsx | 123 ++++++++++-------- .../src/CodeEditor/CodeEditorContext.tsx | 16 ++- .../hooks/extensions/useExtensions.ts | 2 +- packages/code-editor/src/Panel/Panel.tsx | 18 ++- 4 files changed, 94 insertions(+), 65 deletions(-) diff --git a/packages/code-editor/src/CodeEditor/CodeEditor.tsx b/packages/code-editor/src/CodeEditor/CodeEditor.tsx index eab1c5b373..6273ee9211 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'; @@ -372,8 +374,8 @@ const BaseCodeEditor = forwardRef( forceParsingProp, getContents, enableSearchPanel, - props.darkMode, - props.baseFontSize, + darkModeProp, + baseFontSizeProp, panel, searchPanelExtension, isControlled, @@ -406,66 +408,73 @@ const BaseCodeEditor = forwardRef( minWidth, width, readOnly, + darkMode, + baseFontSize, }; return ( - -
- {panel && ( -
- - {panel} - -
- )} - {!panel && - (copyButtonAppearance === CopyButtonAppearance.Hover || - copyButtonAppearance === CopyButtonAppearance.Persist) && ( - +
+ {panel && ( +
+ + {panel} + +
)} - {(isLoadingProp || isLoading) && ( -
- - Loading code editor... - -
- )} -
- + {!panel && + (copyButtonAppearance === CopyButtonAppearance.Hover || + copyButtonAppearance === CopyButtonAppearance.Persist) && ( + + )} + {(isLoadingProp || isLoading) && ( +
+ + Loading code editor... + +
+ )} +
+
+ ); }, ); diff --git a/packages/code-editor/src/CodeEditor/CodeEditorContext.tsx b/packages/code-editor/src/CodeEditor/CodeEditorContext.tsx index 9cde3189d2..ffb5961126 100644 --- a/packages/code-editor/src/CodeEditor/CodeEditorContext.tsx +++ b/packages/code-editor/src/CodeEditor/CodeEditorContext.tsx @@ -1,18 +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 - extends Pick< - CodeEditorProps, - 'maxWidth' | 'minWidth' | 'width' | 'readOnly' - > { + extends DarkModeProps, + BaseFontSizeProps, + Pick { /** * Function to retrieve the current editor contents. */ @@ -88,6 +94,8 @@ const defaultContextValue: CodeEditorContextValue = { readOnly: false, undoDepth: 0, redoDepth: 0, + baseFontSize: 13, + darkMode: false, }; const CodeEditorContext = diff --git a/packages/code-editor/src/CodeEditor/hooks/extensions/useExtensions.ts b/packages/code-editor/src/CodeEditor/hooks/extensions/useExtensions.ts index d02a1e6259..fdb3c388b2 100644 --- a/packages/code-editor/src/CodeEditor/hooks/extensions/useExtensions.ts +++ b/packages/code-editor/src/CodeEditor/hooks/extensions/useExtensions.ts @@ -52,7 +52,7 @@ export function useExtensions({ modules: Partial; hasPanel: boolean; }) { - const baseFontSize = useUpdatedBaseFontSize(); + const baseFontSize = useUpdatedBaseFontSize(props.baseFontSize); const autoCompleteExtension = useAutoCompleteExtension({ editorViewInstance, diff --git a/packages/code-editor/src/Panel/Panel.tsx b/packages/code-editor/src/Panel/Panel.tsx index 826350ef36..8f8aeea03b 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,10 +63,10 @@ export function Panel({ }: PanelProps) { const [shortcutsModalOpen, setShortcutsModalOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false); - const { theme } = useDarkMode(darkMode); - const baseFontSize = useUpdatedBaseFontSize(); const { + baseFontSize: contextBaseFontSize, + darkMode: contextDarkMode, downloadContent, formatCode, getContents, @@ -81,6 +81,11 @@ export function Panel({ width, } = useCodeEditorContext(); + const { theme } = useDarkMode(darkModeProp || contextDarkMode); + const baseFontSize = useUpdatedBaseFontSize( + baseFontSizeProp || contextBaseFontSize, + ); + const handleFormatClick = async () => { if (formatCode) { try { @@ -144,10 +149,13 @@ export function Panel({
{showFormatButton && ( @@ -182,6 +191,7 @@ export function Panel({ open={menuOpen} setOpen={setMenuOpen} data-lgid={lgIds.panelSecondaryMenu} + darkMode={theme === 'dark'} > } @@ -242,6 +252,8 @@ export function Panel({ open={shortcutsModalOpen} setOpen={setShortcutsModalOpen} className={ModalStyles} + initialFocus="auto" + darkMode={theme === 'dark'} > From 50a36ef9e1ea28f28db68f9cc8bc9e8dd0704ffb Mon Sep 17 00:00:00 2001 From: Terrence Keane Date: Tue, 21 Oct 2025 16:21:48 -0400 Subject: [PATCH 20/59] Enhance undo/redo functionality to focus editor and scroll cursor into view after actions --- .../code-editor/src/CodeEditor/CodeEditor.tsx | 36 ++++++++++++++++--- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/packages/code-editor/src/CodeEditor/CodeEditor.tsx b/packages/code-editor/src/CodeEditor/CodeEditor.tsx index 6273ee9211..49db57b7a5 100644 --- a/packages/code-editor/src/CodeEditor/CodeEditor.tsx +++ b/packages/code-editor/src/CodeEditor/CodeEditor.tsx @@ -178,13 +178,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]); /** @@ -193,13 +207,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]); /** From b1d2b5cf1d3eb43d3dd3a155ad8dec1f7319d6d2 Mon Sep 17 00:00:00 2001 From: Terrence Keane Date: Wed, 22 Oct 2025 11:41:17 -0400 Subject: [PATCH 21/59] Add extension initialization tracking to CodeEditor component to prevent unstyled render --- .../code-editor/src/CodeEditor/CodeEditor.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/code-editor/src/CodeEditor/CodeEditor.tsx b/packages/code-editor/src/CodeEditor/CodeEditor.tsx index 49db57b7a5..9d1a25d8f8 100644 --- a/packages/code-editor/src/CodeEditor/CodeEditor.tsx +++ b/packages/code-editor/src/CodeEditor/CodeEditor.tsx @@ -119,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() ?? ''; @@ -297,6 +300,9 @@ const BaseCodeEditor = forwardRef( const domNode = editorContainerRef.current as HTMLElementWithCodeMirror; + // Reset extensions initialized state since we're creating a new editor + setExtensionsInitialized(false); + // Create editor with minimal setup - extensions will be configured in separate effect editorViewRef.current = new EditorView.EditorView({ doc: controlledValue || defaultValue, @@ -388,6 +394,9 @@ const BaseCodeEditor = forwardRef( ]), }); + // Mark extensions as initialized to hide loading overlay + setExtensionsInitialized(true); + if (forceParsingProp) { const Language = modules?.['@codemirror/language']; const docLength = editorViewRef.current?.state.doc.length ?? 0; @@ -478,11 +487,13 @@ const BaseCodeEditor = forwardRef( getContentsToCopy={getContents} className={getCopyButtonStyles(copyButtonAppearance)} variant={CopyButtonVariant.Button} - disabled={isLoadingProp || isLoading} + disabled={ + isLoadingProp || isLoading || !extensionsInitialized + } data-lgid={lgIds.copyButton} /> )} - {(isLoadingProp || isLoading) && ( + {(isLoadingProp || isLoading || !extensionsInitialized) && (
Date: Wed, 22 Oct 2025 12:03:14 -0400 Subject: [PATCH 22/59] Refactor editor initialization to use requestAnimationFrame for better rendering control and optimize extension return with useMemo for performance --- .../code-editor/src/CodeEditor/CodeEditor.tsx | 13 ++++-- .../hooks/extensions/useExtensions.ts | 45 +++++++++++++------ 2 files changed, 41 insertions(+), 17 deletions(-) diff --git a/packages/code-editor/src/CodeEditor/CodeEditor.tsx b/packages/code-editor/src/CodeEditor/CodeEditor.tsx index 9d1a25d8f8..212915393a 100644 --- a/packages/code-editor/src/CodeEditor/CodeEditor.tsx +++ b/packages/code-editor/src/CodeEditor/CodeEditor.tsx @@ -291,6 +291,7 @@ const BaseCodeEditor = forwardRef( hasPanel: !!panel, }); + // Create the editor when modules are loaded useLayoutEffect(() => { const EditorView = modules?.['@codemirror/view']; @@ -300,7 +301,7 @@ const BaseCodeEditor = forwardRef( const domNode = editorContainerRef.current as HTMLElementWithCodeMirror; - // Reset extensions initialized state since we're creating a new editor + // Reset extensions initialized state setExtensionsInitialized(false); // Create editor with minimal setup - extensions will be configured in separate effect @@ -394,8 +395,10 @@ const BaseCodeEditor = forwardRef( ]), }); - // Mark extensions as initialized to hide loading overlay - setExtensionsInitialized(true); + // Wait for next frame to ensure extensions are rendered before hiding loading overlay + const rafId = requestAnimationFrame(() => { + setExtensionsInitialized(true); + }); if (forceParsingProp) { const Language = modules?.['@codemirror/language']; @@ -405,6 +408,10 @@ const BaseCodeEditor = forwardRef( Language.forceParsing(editorViewRef.current, docLength, 150); } } + + return () => { + cancelAnimationFrame(rafId); + }; }, [ consumerExtensions, customExtensions, diff --git a/packages/code-editor/src/CodeEditor/hooks/extensions/useExtensions.ts b/packages/code-editor/src/CodeEditor/hooks/extensions/useExtensions.ts index fdb3c388b2..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'; @@ -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, + ], + ); } From 3238c863d9ba464077d621fa03cfa3f23dbdc065 Mon Sep 17 00:00:00 2001 From: Terrence Keane Date: Fri, 24 Oct 2025 14:16:08 -0400 Subject: [PATCH 23/59] Update contact form header by removing emoji for a cleaner presentation --- packages/code-editor/src/testing/codeSnippets.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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

)} diff --git a/packages/code-editor/tsconfig.json b/packages/code-editor/tsconfig.json index 9cf4002127..a4467c8605 100644 --- a/packages/code-editor/tsconfig.json +++ b/packages/code-editor/tsconfig.json @@ -33,6 +33,9 @@ { "path": "../lib" }, + { + "path": "../loading-indicator" + }, { "path": "../menu" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 931c3880d6..1ddaed469c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1326,6 +1326,9 @@ importers: '@leafygreen-ui/lib': specifier: workspace:^ version: link:../lib + '@leafygreen-ui/loading-indicator': + specifier: workspace:^ + version: link:../loading-indicator '@leafygreen-ui/menu': specifier: workspace:^ version: link:../menu From 5b75e17c6e1ed95d923073064e22199b4ded84e1 Mon Sep 17 00:00:00 2001 From: Terrence Keane Date: Mon, 27 Oct 2025 17:28:41 -0400 Subject: [PATCH 27/59] Fix container width --- .../src/CodeEditor/CodeEditor.styles.ts | 18 ++++++++++++++---- .../code-editor/src/CodeEditor/CodeEditor.tsx | 1 + 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/packages/code-editor/src/CodeEditor/CodeEditor.styles.ts b/packages/code-editor/src/CodeEditor/CodeEditor.styles.ts index 18f0693161..86f7f4a131 100644 --- a/packages/code-editor/src/CodeEditor/CodeEditor.styles.ts +++ b/packages/code-editor/src/CodeEditor/CodeEditor.styles.ts @@ -5,10 +5,12 @@ import { Theme, } from '@leafygreen-ui/lib'; import { + addOverflowShadow, borderRadius, breakpoints, color, InteractionState, + Side, spacing, transitionDuration, Variant, @@ -38,6 +40,7 @@ export const getEditorStyles = ({ maxHeight, className, copyButtonAppearance, + theme, }: { width?: string; minWidth?: string; @@ -47,37 +50,43 @@ export const getEditorStyles = ({ maxHeight?: string; className?: string; copyButtonAppearance?: CopyButtonAppearance; -}) => - cx( - css``, + theme: Theme; +}) => { + return cx( { [css` - ${CodeEditorSelectors.Editor} { + height: ${height}; + ${CodeEditorSelectors.Editor}, ${CodeEditorSelectors.Content}, ${CodeEditorSelectors.Gutters} { 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}; } @@ -95,6 +104,7 @@ export const getEditorStyles = ({ `, className, ); +}; function getHeight( numOfLines: number, diff --git a/packages/code-editor/src/CodeEditor/CodeEditor.tsx b/packages/code-editor/src/CodeEditor/CodeEditor.tsx index cb5e78d217..36fe3ba6dd 100644 --- a/packages/code-editor/src/CodeEditor/CodeEditor.tsx +++ b/packages/code-editor/src/CodeEditor/CodeEditor.tsx @@ -483,6 +483,7 @@ const BaseCodeEditor = forwardRef( maxHeight, className, copyButtonAppearance, + theme, })} data-lgid={lgIds.root} {...rest} From 27c3e98e71725853facfa49ebcd8edb892d0d03e Mon Sep 17 00:00:00 2001 From: Terrence Keane Date: Tue, 28 Oct 2025 11:16:34 -0400 Subject: [PATCH 28/59] Update gutter position style in useThemeExtension to improve shadow visibility --- .../src/CodeEditor/hooks/extensions/useThemeExtension.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/code-editor/src/CodeEditor/hooks/extensions/useThemeExtension.ts b/packages/code-editor/src/CodeEditor/hooks/extensions/useThemeExtension.ts index fef3477dc5..718412b430 100644 --- a/packages/code-editor/src/CodeEditor/hooks/extensions/useThemeExtension.ts +++ b/packages/code-editor/src/CodeEditor/hooks/extensions/useThemeExtension.ts @@ -117,6 +117,8 @@ export function useThemeExtension({ borderBottomLeftRadius: `${borderRadius[300]}px`, fontFamily: fontFamilies.code, fontSize: `${fontSize}px`, + // Forces the gutters to scroll with content to make shadows easier to work with + position: 'static !important', }, [`${CodeEditorSelectors.LineNumbers} ${CodeEditorSelectors.GutterElement}`]: From d4b6c0d06051716a211ae98d83f70a65212d43b3 Mon Sep 17 00:00:00 2001 From: Terrence Keane Date: Tue, 28 Oct 2025 16:03:53 -0400 Subject: [PATCH 29/59] feat(code-editor): render top and bottom shadows --- packages/code-editor/package.json | 1 + .../src/CodeEditor/CodeEditor.styles.ts | 49 ++++++++++++++++++- .../hooks/extensions/useThemeExtension.ts | 10 ++-- pnpm-lock.yaml | 3 ++ 4 files changed, 56 insertions(+), 7 deletions(-) diff --git a/packages/code-editor/package.json b/packages/code-editor/package.json index 80b74e1879..73546c766b 100644 --- a/packages/code-editor/package.json +++ b/packages/code-editor/package.json @@ -72,6 +72,7 @@ "@wasm-fmt/gofmt": "^0.4.9", "@wasm-fmt/ruff_fmt": "^0.10.0", "codemirror": "^6.0.2", + "polished": "^4.3.1", "prettier": "2.8.8" }, "devDependencies": { diff --git a/packages/code-editor/src/CodeEditor/CodeEditor.styles.ts b/packages/code-editor/src/CodeEditor/CodeEditor.styles.ts index 86f7f4a131..b8bfe85a16 100644 --- a/packages/code-editor/src/CodeEditor/CodeEditor.styles.ts +++ b/packages/code-editor/src/CodeEditor/CodeEditor.styles.ts @@ -1,9 +1,12 @@ +import { transparentize } from 'polished'; + import { css, cx } from '@leafygreen-ui/emotion'; import { createUniqueClassName, getMobileMediaQuery, Theme, } from '@leafygreen-ui/lib'; +import { palette } from '@leafygreen-ui/palette'; import { addOverflowShadow, borderRadius, @@ -52,6 +55,22 @@ export const getEditorStyles = ({ copyButtonAppearance?: CopyButtonAppearance; theme: Theme; }) => { + /** + * TODO: This should be removed once addOverflowShadow is updated to accept an override. + */ + const BLUR_RADIUS = 16; + const SHORT_SIDE_SIZE = 36; + const shadowThemeColor: Record = { + [Theme.Light]: palette.gray.dark1, + [Theme.Dark]: palette.black, + }; + const shadowOffset: Record = { + [Theme.Light]: 2, + [Theme.Dark]: 16, + }; + const shadowColor = transparentize(0.7, shadowThemeColor[theme]); + const shadowOffsetVal = shadowOffset[theme]; + return cx( { [css` @@ -100,7 +119,35 @@ export const getEditorStyles = ({ `]: copyButtonAppearance === CopyButtonAppearance.Hover, }, css` - position: relative; + .cm-editor { + position: relative; + overflow: hidden; + + ${addOverflowShadow({ + side: Side.Top, + theme, + isInside: true, + })} + + /** + * TODO: This is a temporary solution to render the bottom shadow. We should update addOverflowShadow. + * to accept an override. The bottom value needs to be different for this to work. + * At the time of development that util was undergoing a big refactor, + * so we didn't want to make any changes to it. + */ + &::after { + content: ''; + position: absolute; + border-radius: 40%; + bottom: -22px; // accounts for scrollbar height + left: 0; + right: 0; + width: 96%; + height: ${SHORT_SIDE_SIZE}px; + margin: 0 auto; + box-shadow: 0 -${shadowOffsetVal}px ${BLUR_RADIUS}px ${shadowColor}; + } + } `, className, ); diff --git a/packages/code-editor/src/CodeEditor/hooks/extensions/useThemeExtension.ts b/packages/code-editor/src/CodeEditor/hooks/extensions/useThemeExtension.ts index 718412b430..5972e2f711 100644 --- a/packages/code-editor/src/CodeEditor/hooks/extensions/useThemeExtension.ts +++ b/packages/code-editor/src/CodeEditor/hooks/extensions/useThemeExtension.ts @@ -93,10 +93,11 @@ export function useThemeExtension({ [CodeEditorSelectors.InnerEditor]: { paddingTop: `${PADDING_TOP}px`, paddingBottom: `${PADDING_BOTTOM}px`, + zIndex: 2, // this is set so that the bottom shadow render below the scrollbar }, [CodeEditorSelectors.FoldPlaceholder]: { - backgroundColor: 'transparent', + background: 'transparent', }, [CodeEditorSelectors.Content]: { @@ -106,10 +107,6 @@ export function useThemeExtension({ }, [CodeEditorSelectors.Gutters]: { - backgroundColor: - color[theme].background[Variant.Primary][ - InteractionState.Default - ], color: color[theme].text[Variant.Secondary][InteractionState.Default], border: 'none', @@ -117,8 +114,9 @@ export function useThemeExtension({ borderBottomLeftRadius: `${borderRadius[300]}px`, fontFamily: fontFamilies.code, fontSize: `${fontSize}px`, - // Forces the gutters to scroll with content to make shadows easier to work with + // Forces the gutters to scroll with content to make shadows work position: 'static !important', + background: 'transparent', }, [`${CodeEditorSelectors.LineNumbers} ${CodeEditorSelectors.GutterElement}`]: diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1ddaed469c..e2cc0e92f8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1374,6 +1374,9 @@ importers: codemirror: specifier: ^6.0.2 version: 6.0.2 + polished: + specifier: ^4.3.1 + version: 4.3.1 prettier: specifier: 2.8.8 version: 2.8.8 From 7224e10ce408c72ae90eb90bdd7e7d7e8be95324 Mon Sep 17 00:00:00 2001 From: Terrence Keane Date: Wed, 29 Oct 2025 09:26:39 -0400 Subject: [PATCH 30/59] fix(code-editor): update selector for editor styles to improve specificity --- packages/code-editor/src/CodeEditor/CodeEditor.styles.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/code-editor/src/CodeEditor/CodeEditor.styles.ts b/packages/code-editor/src/CodeEditor/CodeEditor.styles.ts index b8bfe85a16..bd01025420 100644 --- a/packages/code-editor/src/CodeEditor/CodeEditor.styles.ts +++ b/packages/code-editor/src/CodeEditor/CodeEditor.styles.ts @@ -119,7 +119,7 @@ export const getEditorStyles = ({ `]: copyButtonAppearance === CopyButtonAppearance.Hover, }, css` - .cm-editor { + ${CodeEditorSelectors.Editor} { position: relative; overflow: hidden; From 79186a9b0ed3317ca6305e2e11f45b1aab1cfa9c Mon Sep 17 00:00:00 2001 From: Terrence Keane Date: Wed, 29 Oct 2025 09:48:53 -0400 Subject: [PATCH 31/59] feat(code-editor): add configurable top and bottom shadows to editor styles --- .../src/CodeEditor/CodeEditor.styles.ts | 70 +++++++------------ .../hooks/extensions/useThemeExtension.ts | 2 + 2 files changed, 26 insertions(+), 46 deletions(-) diff --git a/packages/code-editor/src/CodeEditor/CodeEditor.styles.ts b/packages/code-editor/src/CodeEditor/CodeEditor.styles.ts index bd01025420..d04408ca8f 100644 --- a/packages/code-editor/src/CodeEditor/CodeEditor.styles.ts +++ b/packages/code-editor/src/CodeEditor/CodeEditor.styles.ts @@ -44,6 +44,8 @@ export const getEditorStyles = ({ className, copyButtonAppearance, theme, + hasTopShadow = false, + hasBottomShadow = false, }: { width?: string; minWidth?: string; @@ -54,25 +56,12 @@ export const getEditorStyles = ({ className?: string; copyButtonAppearance?: CopyButtonAppearance; theme: Theme; + hasTopShadow?: boolean; + hasBottomShadow?: boolean; }) => { - /** - * TODO: This should be removed once addOverflowShadow is updated to accept an override. - */ - const BLUR_RADIUS = 16; - const SHORT_SIDE_SIZE = 36; - const shadowThemeColor: Record = { - [Theme.Light]: palette.gray.dark1, - [Theme.Dark]: palette.black, - }; - const shadowOffset: Record = { - [Theme.Light]: 2, - [Theme.Dark]: 16, - }; - const shadowColor = transparentize(0.7, shadowThemeColor[theme]); - const shadowOffsetVal = shadowOffset[theme]; - return cx( { + // Dimensions [css` height: ${height}; ${CodeEditorSelectors.Editor}, ${CodeEditorSelectors.Content}, ${CodeEditorSelectors.Gutters} { @@ -117,38 +106,27 @@ export const getEditorStyles = ({ } } `]: copyButtonAppearance === CopyButtonAppearance.Hover, - }, - css` - ${CodeEditorSelectors.Editor} { - position: relative; - overflow: hidden; - ${addOverflowShadow({ - side: Side.Top, - theme, - isInside: true, - })} - - /** - * TODO: This is a temporary solution to render the bottom shadow. We should update addOverflowShadow. - * to accept an override. The bottom value needs to be different for this to work. - * At the time of development that util was undergoing a big refactor, - * so we didn't want to make any changes to it. - */ - &::after { - content: ''; - position: absolute; - border-radius: 40%; - bottom: -22px; // accounts for scrollbar height - left: 0; - right: 0; - width: 96%; - height: ${SHORT_SIDE_SIZE}px; - margin: 0 auto; - box-shadow: 0 -${shadowOffsetVal}px ${BLUR_RADIUS}px ${shadowColor}; + // Overflow Shadows + [css` + ${CodeEditorSelectors.Editor} { + ${addOverflowShadow({ + side: Side.Top, + theme, + isInside: true, + })} } - } - `, + `]: hasTopShadow, + [css` + ${CodeEditorSelectors.Editor} { + ${addOverflowShadow({ + side: Side.Bottom, + theme, + isInside: true, + })} + } + `]: hasBottomShadow, + }, className, ); }; diff --git a/packages/code-editor/src/CodeEditor/hooks/extensions/useThemeExtension.ts b/packages/code-editor/src/CodeEditor/hooks/extensions/useThemeExtension.ts index 5972e2f711..d79a73d127 100644 --- a/packages/code-editor/src/CodeEditor/hooks/extensions/useThemeExtension.ts +++ b/packages/code-editor/src/CodeEditor/hooks/extensions/useThemeExtension.ts @@ -82,6 +82,8 @@ export function useThemeExtension({ borderTopLeftRadius: hasPanel ? 0 : `${borderRadius[300]}px`, borderTopRightRadius: hasPanel ? 0 : `${borderRadius[300]}px`, color: color[theme].text[Variant.Primary][InteractionState.Default], + position: 'relative', + overflow: 'hidden', }, [`&${CodeEditorSelectors.Focused}`]: { From 67019ea084f72b42a29797880a28b3cf15ae00e8 Mon Sep 17 00:00:00 2001 From: Terrence Keane Date: Wed, 29 Oct 2025 09:53:47 -0400 Subject: [PATCH 32/59] refactor(code-editor): rename InnerEditor selector to Scroller for consistency --- packages/code-editor/src/CodeEditor/CodeEditor.types.ts | 2 +- .../src/CodeEditor/hooks/extensions/useThemeExtension.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/code-editor/src/CodeEditor/CodeEditor.types.ts b/packages/code-editor/src/CodeEditor/CodeEditor.types.ts index c5ffded90a..8323e16283 100644 --- a/packages/code-editor/src/CodeEditor/CodeEditor.types.ts +++ b/packages/code-editor/src/CodeEditor/CodeEditor.types.ts @@ -92,7 +92,7 @@ export const CodeEditorSelectors = { 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/hooks/extensions/useThemeExtension.ts b/packages/code-editor/src/CodeEditor/hooks/extensions/useThemeExtension.ts index d79a73d127..8454e1badf 100644 --- a/packages/code-editor/src/CodeEditor/hooks/extensions/useThemeExtension.ts +++ b/packages/code-editor/src/CodeEditor/hooks/extensions/useThemeExtension.ts @@ -92,7 +92,7 @@ export function useThemeExtension({ ${color[theme].border[Variant.Secondary][InteractionState.Default]}`, }, - [CodeEditorSelectors.InnerEditor]: { + [CodeEditorSelectors.Scroller]: { paddingTop: `${PADDING_TOP}px`, paddingBottom: `${PADDING_BOTTOM}px`, zIndex: 2, // this is set so that the bottom shadow render below the scrollbar From e98aabebc453361e445eb917b8fb999f45d3d999 Mon Sep 17 00:00:00 2001 From: Terrence Keane Date: Wed, 29 Oct 2025 10:03:03 -0400 Subject: [PATCH 33/59] chore(code-editor): remove polished dependency and related imports from package.json and styles --- packages/code-editor/package.json | 1 - packages/code-editor/src/CodeEditor/CodeEditor.styles.ts | 3 --- pnpm-lock.yaml | 3 --- 3 files changed, 7 deletions(-) diff --git a/packages/code-editor/package.json b/packages/code-editor/package.json index 73546c766b..80b74e1879 100644 --- a/packages/code-editor/package.json +++ b/packages/code-editor/package.json @@ -72,7 +72,6 @@ "@wasm-fmt/gofmt": "^0.4.9", "@wasm-fmt/ruff_fmt": "^0.10.0", "codemirror": "^6.0.2", - "polished": "^4.3.1", "prettier": "2.8.8" }, "devDependencies": { diff --git a/packages/code-editor/src/CodeEditor/CodeEditor.styles.ts b/packages/code-editor/src/CodeEditor/CodeEditor.styles.ts index d04408ca8f..77f18a992c 100644 --- a/packages/code-editor/src/CodeEditor/CodeEditor.styles.ts +++ b/packages/code-editor/src/CodeEditor/CodeEditor.styles.ts @@ -1,12 +1,9 @@ -import { transparentize } from 'polished'; - import { css, cx } from '@leafygreen-ui/emotion'; import { createUniqueClassName, getMobileMediaQuery, Theme, } from '@leafygreen-ui/lib'; -import { palette } from '@leafygreen-ui/palette'; import { addOverflowShadow, borderRadius, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e2cc0e92f8..1ddaed469c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1374,9 +1374,6 @@ importers: codemirror: specifier: ^6.0.2 version: 6.0.2 - polished: - specifier: ^4.3.1 - version: 4.3.1 prettier: specifier: 2.8.8 version: 2.8.8 From ab0337d1bab9af2ff4fe00b6b6875528c78eba42 Mon Sep 17 00:00:00 2001 From: Terrence Keane Date: Wed, 29 Oct 2025 10:12:06 -0400 Subject: [PATCH 34/59] feat(code-editor): implement scroll shadows for overflow indication in editor --- .../code-editor/src/CodeEditor/CodeEditor.tsx | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/packages/code-editor/src/CodeEditor/CodeEditor.tsx b/packages/code-editor/src/CodeEditor/CodeEditor.tsx index 36fe3ba6dd..3e88dc8651 100644 --- a/packages/code-editor/src/CodeEditor/CodeEditor.tsx +++ b/packages/code-editor/src/CodeEditor/CodeEditor.tsx @@ -32,6 +32,7 @@ import { import { CodeEditorHandle, type CodeEditorProps, + CodeEditorSelectors, CodeEditorSubcomponentProperty, type CodeMirrorExtension, CopyButtonAppearance, @@ -90,6 +91,8 @@ const BaseCodeEditor = forwardRef( const editorViewRef = useRef(null); const [undoDepth, setUndoDepth] = useState(0); const [redoDepth, setRedoDepth] = useState(0); + const [hasTopShadow, setHasTopShadow] = useState(false); + const [hasBottomShadow, setHasBottomShadow] = useState(false); const { modules, isLoading } = useModules(props); @@ -428,6 +431,49 @@ const BaseCodeEditor = forwardRef( onChangeProp, ]); + // Handle scroll shadows for overflow indication + useLayoutEffect(() => { + if (!editorContainerRef.current || !editorViewRef.current) { + return; + } + + const scrollerElement = editorContainerRef.current.querySelector( + CodeEditorSelectors.Scroller, // CM Element that handles scrolling + ) as HTMLElement | null; + + if (!scrollerElement) { + return; + } + + const updateScrollShadows = () => { + const hasTop = scrollerElement.scrollTop > 0; + const hasBottom = + Math.abs( + scrollerElement.scrollHeight - + scrollerElement.clientHeight - + scrollerElement.scrollTop, + ) >= 1; + + setHasTopShadow(hasTop); + setHasBottomShadow(hasBottom); + }; + + // Initial check + updateScrollShadows(); + + // Listen for scroll events + scrollerElement.addEventListener('scroll', updateScrollShadows); + + // Also check on resize in case content changes + const resizeObserver = new ResizeObserver(updateScrollShadows); + resizeObserver.observe(scrollerElement); + + return () => { + scrollerElement.removeEventListener('scroll', updateScrollShadows); + resizeObserver.disconnect(); + }; + }, [modules]); + useImperativeHandle(forwardedRef, () => ({ getEditorViewInstance: () => editorViewRef.current, getContents, @@ -484,6 +530,8 @@ const BaseCodeEditor = forwardRef( className, copyButtonAppearance, theme, + hasTopShadow, + hasBottomShadow, })} data-lgid={lgIds.root} {...rest} From 20498847bc03de6bb32e8da287080d5e164bc89e Mon Sep 17 00:00:00 2001 From: Terrence Keane Date: Wed, 29 Oct 2025 10:39:20 -0400 Subject: [PATCH 35/59] feat(code-editor): integrate lodash for debounced scroll shadow updates --- packages/code-editor/package.json | 1 + .../code-editor/src/CodeEditor/CodeEditor.tsx | 52 +++++++++++++------ pnpm-lock.yaml | 3 ++ 3 files changed, 39 insertions(+), 17 deletions(-) diff --git a/packages/code-editor/package.json b/packages/code-editor/package.json index 80b74e1879..a5e64c6ed3 100644 --- a/packages/code-editor/package.json +++ b/packages/code-editor/package.json @@ -72,6 +72,7 @@ "@wasm-fmt/gofmt": "^0.4.9", "@wasm-fmt/ruff_fmt": "^0.10.0", "codemirror": "^6.0.2", + "lodash": "^4.17.21", "prettier": "2.8.8" }, "devDependencies": { diff --git a/packages/code-editor/src/CodeEditor/CodeEditor.tsx b/packages/code-editor/src/CodeEditor/CodeEditor.tsx index 3e88dc8651..69b6c5abd1 100644 --- a/packages/code-editor/src/CodeEditor/CodeEditor.tsx +++ b/packages/code-editor/src/CodeEditor/CodeEditor.tsx @@ -3,10 +3,12 @@ import React, { useCallback, useImperativeHandle, useLayoutEffect, + useMemo, useRef, useState, } from 'react'; import { type EditorView, type ViewUpdate } from '@codemirror/view'; +import debounce from 'lodash/debounce'; import LeafyGreenProvider, { useDarkMode, @@ -431,6 +433,29 @@ const BaseCodeEditor = forwardRef( onChangeProp, ]); + // Debounced scroll shadow update function + const debouncedUpdateScrollShadows = useMemo( + () => + debounce( + (scrollerElement: HTMLElement) => { + const hasTop = scrollerElement.scrollTop > 0; + const hasBottom = + Math.abs( + scrollerElement.scrollHeight - + scrollerElement.clientHeight - + scrollerElement.scrollTop, + ) >= 1; + + // Only update state if values have actually changed + setHasTopShadow(prev => (prev !== hasTop ? hasTop : prev)); + setHasBottomShadow(prev => (prev !== hasBottom ? hasBottom : prev)); + }, + 50, + { leading: true }, + ), + [], + ); + // Handle scroll shadows for overflow indication useLayoutEffect(() => { if (!editorContainerRef.current || !editorViewRef.current) { @@ -445,34 +470,27 @@ const BaseCodeEditor = forwardRef( return; } - const updateScrollShadows = () => { - const hasTop = scrollerElement.scrollTop > 0; - const hasBottom = - Math.abs( - scrollerElement.scrollHeight - - scrollerElement.clientHeight - - scrollerElement.scrollTop, - ) >= 1; - - setHasTopShadow(hasTop); - setHasBottomShadow(hasBottom); + const handleScroll = () => { + debouncedUpdateScrollShadows(scrollerElement); }; - // Initial check - updateScrollShadows(); + // Initial check (immediate, no debounce) + debouncedUpdateScrollShadows(scrollerElement); + debouncedUpdateScrollShadows.flush(); // Execute immediately for initial state // Listen for scroll events - scrollerElement.addEventListener('scroll', updateScrollShadows); + scrollerElement.addEventListener('scroll', handleScroll); // Also check on resize in case content changes - const resizeObserver = new ResizeObserver(updateScrollShadows); + const resizeObserver = new ResizeObserver(handleScroll); resizeObserver.observe(scrollerElement); return () => { - scrollerElement.removeEventListener('scroll', updateScrollShadows); + scrollerElement.removeEventListener('scroll', handleScroll); resizeObserver.disconnect(); + debouncedUpdateScrollShadows.cancel(); // Cancel any pending debounced calls }; - }, [modules]); + }, [modules, debouncedUpdateScrollShadows]); useImperativeHandle(forwardedRef, () => ({ getEditorViewInstance: () => editorViewRef.current, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1ddaed469c..191c0d04b6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1374,6 +1374,9 @@ importers: codemirror: specifier: ^6.0.2 version: 6.0.2 + lodash: + specifier: ^4.17.21 + version: 4.17.21 prettier: specifier: 2.8.8 version: 2.8.8 From 39ff90b3a9047ebf5952486927f77b29841d8d34 Mon Sep 17 00:00:00 2001 From: Terrence Keane Date: Wed, 29 Oct 2025 10:57:01 -0400 Subject: [PATCH 36/59] refactor(code-editor): remove lodash dependency and optimize scroll shadow handling --- packages/code-editor/package.json | 1 - .../src/CodeEditor/CodeEditor.styles.ts | 14 ++-- .../code-editor/src/CodeEditor/CodeEditor.tsx | 77 ++++++++++--------- 3 files changed, 47 insertions(+), 45 deletions(-) diff --git a/packages/code-editor/package.json b/packages/code-editor/package.json index a5e64c6ed3..80b74e1879 100644 --- a/packages/code-editor/package.json +++ b/packages/code-editor/package.json @@ -72,7 +72,6 @@ "@wasm-fmt/gofmt": "^0.4.9", "@wasm-fmt/ruff_fmt": "^0.10.0", "codemirror": "^6.0.2", - "lodash": "^4.17.21", "prettier": "2.8.8" }, "devDependencies": { diff --git a/packages/code-editor/src/CodeEditor/CodeEditor.styles.ts b/packages/code-editor/src/CodeEditor/CodeEditor.styles.ts index 77f18a992c..09b4c29f95 100644 --- a/packages/code-editor/src/CodeEditor/CodeEditor.styles.ts +++ b/packages/code-editor/src/CodeEditor/CodeEditor.styles.ts @@ -41,8 +41,6 @@ export const getEditorStyles = ({ className, copyButtonAppearance, theme, - hasTopShadow = false, - hasBottomShadow = false, }: { width?: string; minWidth?: string; @@ -53,8 +51,6 @@ export const getEditorStyles = ({ className?: string; copyButtonAppearance?: CopyButtonAppearance; theme: Theme; - hasTopShadow?: boolean; - hasBottomShadow?: boolean; }) => { return cx( { @@ -104,25 +100,25 @@ export const getEditorStyles = ({ } `]: copyButtonAppearance === CopyButtonAppearance.Hover, - // Overflow Shadows + // Overflow Shadows (applied via classes for performance) [css` - ${CodeEditorSelectors.Editor} { + &.lg-code-editor-has-top-shadow ${CodeEditorSelectors.Editor} { ${addOverflowShadow({ side: Side.Top, theme, isInside: true, })} } - `]: hasTopShadow, + `]: true, [css` - ${CodeEditorSelectors.Editor} { + &.lg-code-editor-has-bottom-shadow ${CodeEditorSelectors.Editor} { ${addOverflowShadow({ side: Side.Bottom, theme, isInside: true, })} } - `]: hasBottomShadow, + `]: true, }, className, ); diff --git a/packages/code-editor/src/CodeEditor/CodeEditor.tsx b/packages/code-editor/src/CodeEditor/CodeEditor.tsx index 69b6c5abd1..97b6175098 100644 --- a/packages/code-editor/src/CodeEditor/CodeEditor.tsx +++ b/packages/code-editor/src/CodeEditor/CodeEditor.tsx @@ -1,14 +1,13 @@ import React, { forwardRef, useCallback, + useEffect, useImperativeHandle, useLayoutEffect, - useMemo, useRef, useState, } from 'react'; import { type EditorView, type ViewUpdate } from '@codemirror/view'; -import debounce from 'lodash/debounce'; import LeafyGreenProvider, { useDarkMode, @@ -93,8 +92,9 @@ const BaseCodeEditor = forwardRef( const editorViewRef = useRef(null); const [undoDepth, setUndoDepth] = useState(0); const [redoDepth, setRedoDepth] = useState(0); - const [hasTopShadow, setHasTopShadow] = useState(false); - const [hasBottomShadow, setHasBottomShadow] = useState(false); + // Use refs to track shadow state without causing re-renders during scroll + const hasTopShadowRef = useRef(false); + const hasBottomShadowRef = useRef(false); const { modules, isLoading } = useModules(props); @@ -433,36 +433,45 @@ const BaseCodeEditor = forwardRef( onChangeProp, ]); - // Debounced scroll shadow update function - const debouncedUpdateScrollShadows = useMemo( - () => - debounce( - (scrollerElement: HTMLElement) => { - const hasTop = scrollerElement.scrollTop > 0; - const hasBottom = - Math.abs( - scrollerElement.scrollHeight - - scrollerElement.clientHeight - - scrollerElement.scrollTop, - ) >= 1; - - // Only update state if values have actually changed - setHasTopShadow(prev => (prev !== hasTop ? hasTop : prev)); - setHasBottomShadow(prev => (prev !== hasBottom ? hasBottom : prev)); - }, - 50, - { leading: true }, - ), + // Update shadow state and apply to DOM immediately without re-render + const updateScrollShadows = useCallback( + (scrollerElement: HTMLElement, containerElement: HTMLElement) => { + const hasTop = scrollerElement.scrollTop > 0; + const hasBottom = + Math.abs( + scrollerElement.scrollHeight - + scrollerElement.clientHeight - + scrollerElement.scrollTop, + ) >= 1; + + // Update refs (no re-render) + hasTopShadowRef.current = hasTop; + hasBottomShadowRef.current = hasBottom; + + // Apply classes directly to DOM (no re-render) + if (hasTop) { + containerElement.classList.add('lg-code-editor-has-top-shadow'); + } else { + containerElement.classList.remove('lg-code-editor-has-top-shadow'); + } + + if (hasBottom) { + containerElement.classList.add('lg-code-editor-has-bottom-shadow'); + } else { + containerElement.classList.remove('lg-code-editor-has-bottom-shadow'); + } + }, [], ); // Handle scroll shadows for overflow indication - useLayoutEffect(() => { + useEffect(() => { if (!editorContainerRef.current || !editorViewRef.current) { return; } - const scrollerElement = editorContainerRef.current.querySelector( + const containerElement = editorContainerRef.current; + const scrollerElement = containerElement.querySelector( CodeEditorSelectors.Scroller, // CM Element that handles scrolling ) as HTMLElement | null; @@ -471,15 +480,16 @@ const BaseCodeEditor = forwardRef( } const handleScroll = () => { - debouncedUpdateScrollShadows(scrollerElement); + updateScrollShadows(scrollerElement, containerElement); }; - // Initial check (immediate, no debounce) - debouncedUpdateScrollShadows(scrollerElement); - debouncedUpdateScrollShadows.flush(); // Execute immediately for initial state + // Initial check + updateScrollShadows(scrollerElement, containerElement); // Listen for scroll events - scrollerElement.addEventListener('scroll', handleScroll); + scrollerElement.addEventListener('scroll', handleScroll, { + passive: true, + }); // Also check on resize in case content changes const resizeObserver = new ResizeObserver(handleScroll); @@ -488,9 +498,8 @@ const BaseCodeEditor = forwardRef( return () => { scrollerElement.removeEventListener('scroll', handleScroll); resizeObserver.disconnect(); - debouncedUpdateScrollShadows.cancel(); // Cancel any pending debounced calls }; - }, [modules, debouncedUpdateScrollShadows]); + }, [modules, updateScrollShadows]); useImperativeHandle(forwardedRef, () => ({ getEditorViewInstance: () => editorViewRef.current, @@ -548,8 +557,6 @@ const BaseCodeEditor = forwardRef( className, copyButtonAppearance, theme, - hasTopShadow, - hasBottomShadow, })} data-lgid={lgIds.root} {...rest} From ebff3158ffdbbd5b2c2da905d9f6dbb4dfa73f94 Mon Sep 17 00:00:00 2001 From: Terrence Keane Date: Wed, 29 Oct 2025 10:58:50 -0400 Subject: [PATCH 37/59] refactor(code-editor): remove unused styles from useThemeExtension for cleaner code --- .../src/CodeEditor/hooks/extensions/useThemeExtension.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/code-editor/src/CodeEditor/hooks/extensions/useThemeExtension.ts b/packages/code-editor/src/CodeEditor/hooks/extensions/useThemeExtension.ts index 8454e1badf..202d2568d8 100644 --- a/packages/code-editor/src/CodeEditor/hooks/extensions/useThemeExtension.ts +++ b/packages/code-editor/src/CodeEditor/hooks/extensions/useThemeExtension.ts @@ -82,7 +82,6 @@ export function useThemeExtension({ borderTopLeftRadius: hasPanel ? 0 : `${borderRadius[300]}px`, borderTopRightRadius: hasPanel ? 0 : `${borderRadius[300]}px`, color: color[theme].text[Variant.Primary][InteractionState.Default], - position: 'relative', overflow: 'hidden', }, @@ -95,7 +94,6 @@ export function useThemeExtension({ [CodeEditorSelectors.Scroller]: { paddingTop: `${PADDING_TOP}px`, paddingBottom: `${PADDING_BOTTOM}px`, - zIndex: 2, // this is set so that the bottom shadow render below the scrollbar }, [CodeEditorSelectors.FoldPlaceholder]: { From e3a8a4d3e11d8aa645aa04ad6775cefb06f758bb Mon Sep 17 00:00:00 2001 From: Terrence Keane Date: Wed, 29 Oct 2025 11:04:00 -0400 Subject: [PATCH 38/59] Fix inner height issue --- packages/code-editor/src/CodeEditor/CodeEditor.styles.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/code-editor/src/CodeEditor/CodeEditor.styles.ts b/packages/code-editor/src/CodeEditor/CodeEditor.styles.ts index 09b4c29f95..988bc61dd1 100644 --- a/packages/code-editor/src/CodeEditor/CodeEditor.styles.ts +++ b/packages/code-editor/src/CodeEditor/CodeEditor.styles.ts @@ -57,7 +57,7 @@ export const getEditorStyles = ({ // Dimensions [css` height: ${height}; - ${CodeEditorSelectors.Editor}, ${CodeEditorSelectors.Content}, ${CodeEditorSelectors.Gutters} { + ${CodeEditorSelectors.Editor} { height: ${height}; } `]: !!height, From cc24e128805bdbe4f8f00fcb43d7cdc7d2bba47b Mon Sep 17 00:00:00 2001 From: Terrence Keane Date: Wed, 29 Oct 2025 11:24:56 -0400 Subject: [PATCH 39/59] feat(code-editor): add LoadingWithPanel story and adjust styles for panel integration --- .../code-editor/src/CodeEditor.stories.tsx | 21 +++++++++++++++++++ .../src/CodeEditor/CodeEditor.styles.ts | 14 +++++++++++-- .../code-editor/src/CodeEditor/CodeEditor.tsx | 1 + .../code-editor/src/Panel/Panel.styles.ts | 2 +- packages/code-editor/src/Panel/index.ts | 1 + 5 files changed, 36 insertions(+), 3 deletions(-) diff --git a/packages/code-editor/src/CodeEditor.stories.tsx b/packages/code-editor/src/CodeEditor.stories.tsx index fcfb09623b..44d7b70dd3 100644 --- a/packages/code-editor/src/CodeEditor.stories.tsx +++ b/packages/code-editor/src/CodeEditor.stories.tsx @@ -261,6 +261,27 @@ Loading.args = { isLoading: true, }; +export const LoadingWithPanel = Template.bind({}); +LoadingWithPanel.args = { + isLoading: true, + 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.styles.ts b/packages/code-editor/src/CodeEditor/CodeEditor.styles.ts index 988bc61dd1..08d83606b7 100644 --- a/packages/code-editor/src/CodeEditor/CodeEditor.styles.ts +++ b/packages/code-editor/src/CodeEditor/CodeEditor.styles.ts @@ -16,6 +16,8 @@ import { Variant, } from '@leafygreen-ui/tokens'; +import { PANEL_HEIGHT } from '../Panel'; + import { LINE_HEIGHT, PADDING_BOTTOM, @@ -53,6 +55,9 @@ export const getEditorStyles = ({ theme: Theme; }) => { return cx( + css` + position: relative; + `, { // Dimensions [css` @@ -151,6 +156,7 @@ export const getLoaderStyles = ({ baseFontSize, numOfLines, isLoading, + hasPanel, }: { theme: Theme; baseFontSize: CodeEditorProps['baseFontSize']; @@ -162,6 +168,7 @@ export const getLoaderStyles = ({ maxHeight?: string; numOfLines: number; isLoading: boolean; + hasPanel: boolean; }) => { const fontSize = baseFontSize ? baseFontSize : 13; const defaultHeight = getHeight(numOfLines, fontSize); @@ -182,12 +189,15 @@ export const getLoaderStyles = ({ ]}; 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'}; diff --git a/packages/code-editor/src/CodeEditor/CodeEditor.tsx b/packages/code-editor/src/CodeEditor/CodeEditor.tsx index 97b6175098..41dc1ae4ba 100644 --- a/packages/code-editor/src/CodeEditor/CodeEditor.tsx +++ b/packages/code-editor/src/CodeEditor/CodeEditor.tsx @@ -594,6 +594,7 @@ const BaseCodeEditor = forwardRef( baseFontSize, numOfLines, isLoading, + hasPanel: !!panel, })} data-lgid={lgIds.loader} > diff --git a/packages/code-editor/src/Panel/Panel.styles.ts b/packages/code-editor/src/Panel/Panel.styles.ts index 9d3e76c7da..ef40a84bb6 100644 --- a/packages/code-editor/src/Panel/Panel.styles.ts +++ b/packages/code-editor/src/Panel/Panel.styles.ts @@ -8,7 +8,7 @@ import { Variant, } from '@leafygreen-ui/tokens'; -const PANEL_HEIGHT = 36; +export const PANEL_HEIGHT = 36; const MODAL_HEIGHT = 354; const getBasePanelStyles = (theme: Theme) => css` 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'; From dc4822f1d5ba82523531297a9223bb4a3f6d91a6 Mon Sep 17 00:00:00 2001 From: Terrence Keane Date: Wed, 29 Oct 2025 11:53:55 -0400 Subject: [PATCH 40/59] Fix panel tests --- packages/code-editor/src/Panel/Panel.spec.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/code-editor/src/Panel/Panel.spec.tsx b/packages/code-editor/src/Panel/Panel.spec.tsx index e7af3385c8..86edf3a30f 100644 --- a/packages/code-editor/src/Panel/Panel.spec.tsx +++ b/packages/code-editor/src/Panel/Panel.spec.tsx @@ -7,13 +7,19 @@ import { PanelProps } from './Panel.types'; // Mock Modal component to avoid HTMLDialogElement issues jest.mock('@leafygreen-ui/modal', () => { - return function MockModal({ children, open, ...props }: any) { + const MockModal = function ({ children, open, ...props }: any) { return open ? (
{children}
) : null; }; + + return { + __esModule: true, + default: MockModal, + Modal: MockModal, + }; }); const TestIcon = () =>
; From 7289b02e81df8abe2f0c20dae90ee2984d9a8563 Mon Sep 17 00:00:00 2001 From: Terrence Keane Date: Wed, 29 Oct 2025 11:54:01 -0400 Subject: [PATCH 41/59] Remove Spinner --- packages/code-editor/package.json | 1 - packages/code-editor/src/CodeEditor/CodeEditor.tsx | 4 +--- pnpm-lock.yaml | 6 ------ 3 files changed, 1 insertion(+), 10 deletions(-) diff --git a/packages/code-editor/package.json b/packages/code-editor/package.json index 80b74e1879..e6c3ae5baf 100644 --- a/packages/code-editor/package.json +++ b/packages/code-editor/package.json @@ -56,7 +56,6 @@ "@leafygreen-ui/input-option": "workspace:^", "@leafygreen-ui/leafygreen-provider": "workspace:^", "@leafygreen-ui/lib": "workspace:^", - "@leafygreen-ui/loading-indicator": "workspace:^", "@leafygreen-ui/menu": "workspace:^", "@leafygreen-ui/modal": "workspace:^", "@leafygreen-ui/palette": "workspace:^", diff --git a/packages/code-editor/src/CodeEditor/CodeEditor.tsx b/packages/code-editor/src/CodeEditor/CodeEditor.tsx index 41dc1ae4ba..33841ed440 100644 --- a/packages/code-editor/src/CodeEditor/CodeEditor.tsx +++ b/packages/code-editor/src/CodeEditor/CodeEditor.tsx @@ -13,7 +13,6 @@ import LeafyGreenProvider, { useDarkMode, } from '@leafygreen-ui/leafygreen-provider'; import { findChild } from '@leafygreen-ui/lib'; -import { Size, Spinner } from '@leafygreen-ui/loading-indicator/spinner'; import { Body, useUpdatedBaseFontSize } from '@leafygreen-ui/typography'; import { CodeEditorContextMenu } from '../CodeEditorContextMenu'; @@ -599,8 +598,7 @@ const BaseCodeEditor = forwardRef( data-lgid={lgIds.loader} > - - Loading code editor + Loading code editor...
)} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 191c0d04b6..931c3880d6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1326,9 +1326,6 @@ importers: '@leafygreen-ui/lib': specifier: workspace:^ version: link:../lib - '@leafygreen-ui/loading-indicator': - specifier: workspace:^ - version: link:../loading-indicator '@leafygreen-ui/menu': specifier: workspace:^ version: link:../menu @@ -1374,9 +1371,6 @@ importers: codemirror: specifier: ^6.0.2 version: 6.0.2 - lodash: - specifier: ^4.17.21 - version: 4.17.21 prettier: specifier: 2.8.8 version: 2.8.8 From ba4988231a4bd5ae4f2cb0d951c61722b26483dd Mon Sep 17 00:00:00 2001 From: Terrence Keane Date: Wed, 29 Oct 2025 13:32:08 -0400 Subject: [PATCH 42/59] WIP: Fix tests --- .../src/CodeEditor/CodeEditor.testUtils.tsx | 106 +++++++-- .../code-editor/src/CodeEditor/CodeEditor.tsx | 3 +- .../src/CodeEditor/CodeEditor2.spec.tsx | 201 ++++++++++++++++++ 3 files changed, 287 insertions(+), 23 deletions(-) create mode 100644 packages/code-editor/src/CodeEditor/CodeEditor2.spec.tsx diff --git a/packages/code-editor/src/CodeEditor/CodeEditor.testUtils.tsx b/packages/code-editor/src/CodeEditor/CodeEditor.testUtils.tsx index 3f8bbb7c58..bbeb6c047f 100644 --- a/packages/code-editor/src/CodeEditor/CodeEditor.testUtils.tsx +++ b/packages/code-editor/src/CodeEditor/CodeEditor.testUtils.tsx @@ -1,8 +1,42 @@ import React from 'react'; +import * as codemirrorAutocomplete from '@codemirror/autocomplete'; +import * as codemirrorCommands from '@codemirror/commands'; +// Language-specific imports +import * as langCpp from '@codemirror/lang-cpp'; +import * as langGo from '@codemirror/lang-go'; +import * as langHtml from '@codemirror/lang-html'; +import * as langJava from '@codemirror/lang-java'; +import * as langJavaScript from '@codemirror/lang-javascript'; +import * as langJson from '@codemirror/lang-json'; +import * as langPhp from '@codemirror/lang-php'; +import * as langPython from '@codemirror/lang-python'; +import * as langRust from '@codemirror/lang-rust'; import { indentUnit } from '@codemirror/language'; +import * as codemirrorLanguage from '@codemirror/language'; +import * as legacyModeClike from '@codemirror/legacy-modes/mode/clike'; +import * as legacyModeRuby from '@codemirror/legacy-modes/mode/ruby'; +import * as codemirrorLint from '@codemirror/lint'; +import * as codemirrorSearch from '@codemirror/search'; import { type ChangeSpec } from '@codemirror/state'; -import { render, waitFor } from '@testing-library/react'; - +import * as codemirrorState from '@codemirror/state'; +import * as codemirrorView from '@codemirror/view'; +import * as lezerHighlight from '@lezer/highlight'; +import * as langCSharp from '@replit/codemirror-lang-csharp'; +import { render } from '@testing-library/react'; +import * as hyperLink from '@uiw/codemirror-extensions-hyper-link'; +// WASM formatting modules +import * as wasmClangFormat from '@wasm-fmt/clang-format'; +import * as wasmGofmt from '@wasm-fmt/gofmt'; +import * as wasmRuffFmt from '@wasm-fmt/ruff_fmt'; +// Import all CodeMirror modules for synchronous testing +import * as codemirror from 'codemirror'; +import * as prettierParserBabel from 'prettier/parser-babel'; +import * as prettierParserHtml from 'prettier/parser-html'; +import * as prettierParserTypescript from 'prettier/parser-typescript'; +// Prettier formatting modules +import * as prettierStandalone from 'prettier/standalone'; + +import { type CodeEditorModules } from './hooks'; import { CodeEditor, CodeEditorProps, @@ -10,29 +44,55 @@ import { CodeMirrorView, } from '.'; +import * as langCss from '@codemirror/lang-css'; +import * as prettierParserPostcss from 'prettier/parser-postcss'; + +/** + * Pre-loaded modules for synchronous testing. + * Contains all possible CodeMirror modules to avoid async loading in tests. + */ +const preLoadedModules: CodeEditorModules = { + codemirror, + '@codemirror/view': codemirrorView, + '@codemirror/state': codemirrorState, + '@codemirror/commands': codemirrorCommands, + '@codemirror/language': codemirrorLanguage, + '@codemirror/lint': codemirrorLint, + '@codemirror/autocomplete': codemirrorAutocomplete, + '@codemirror/search': codemirrorSearch, + '@lezer/highlight': lezerHighlight, + '@uiw/codemirror-extensions-hyper-link': hyperLink, + '@codemirror/lang-cpp': langCpp, + '@replit/codemirror-lang-csharp': langCSharp, + '@codemirror/lang-css': langCss, + '@codemirror/lang-go': langGo, + '@codemirror/lang-html': langHtml, + '@codemirror/lang-java': langJava, + '@codemirror/lang-javascript': langJavaScript, + '@codemirror/lang-json': langJson, + '@codemirror/lang-php': langPhp, + '@codemirror/lang-python': langPython, + '@codemirror/lang-rust': langRust, + '@codemirror/legacy-modes/mode/clike': legacyModeClike, + '@codemirror/legacy-modes/mode/ruby': legacyModeRuby, + 'prettier/standalone': prettierStandalone, + 'prettier/parser-babel': prettierParserBabel, + 'prettier/parser-typescript': prettierParserTypescript, + 'prettier/parser-postcss': prettierParserPostcss, + 'prettier/parser-html': prettierParserHtml, +}; + 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 + * Gets the editor view instance. With preloaded modules, the editor view is available + * immediately after render, so this resolves synchronously. + * Kept as async for backward compatibility with existing tests. + * @returns Promise that resolves to the CodeMirror view instance + * @throws Error if editor view is not available */ -async function waitForEditorView(timeout = 5000): Promise { - await waitFor( - () => { - const view = getEditorViewFn?.(); - - if (!view) { - throw new Error('Editor view not available yet'); - } - editorViewInstance = view; - }, - { timeout }, - ); - +async function waitForEditorView(): Promise { return ensureEditorView(); } @@ -261,7 +321,8 @@ export const editor = { }; /** - * 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,14 +330,15 @@ export const editor = { */ export function renderCodeEditor( props: Partial = {}, + moduleOverrides?: Partial, options?: { children?: React.ReactNode }, ) { const { children } = options || {}; const { container } = render( { - getEditorViewFn = ref?.getEditorViewInstance ?? null; editorViewInstance = ref?.getEditorViewInstance() ?? null; editorHandleInstance = ref; }} diff --git a/packages/code-editor/src/CodeEditor/CodeEditor.tsx b/packages/code-editor/src/CodeEditor/CodeEditor.tsx index 33841ed440..e7398007a1 100644 --- a/packages/code-editor/src/CodeEditor/CodeEditor.tsx +++ b/packages/code-editor/src/CodeEditor/CodeEditor.tsx @@ -408,8 +408,9 @@ const BaseCodeEditor = forwardRef( if (forceParsingProp) { const Language = modules?.['@codemirror/language']; const docLength = editorViewRef.current?.state.doc.length ?? 0; - + console.log('forceParsing true'); if (Language && Language.forceParsing && docLength > 0) { + console.log('forceParsing called'); Language.forceParsing(editorViewRef.current, docLength, 150); } } diff --git a/packages/code-editor/src/CodeEditor/CodeEditor2.spec.tsx b/packages/code-editor/src/CodeEditor/CodeEditor2.spec.tsx new file mode 100644 index 0000000000..bb516dc6f8 --- /dev/null +++ b/packages/code-editor/src/CodeEditor/CodeEditor2.spec.tsx @@ -0,0 +1,201 @@ +import React from 'react'; +import { act, waitFor } from '@testing-library/react'; + +import { renderCodeEditor } from './CodeEditor.testUtils'; +import { CodeEditorSelectors } from './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: [], +})); + +describe('packages/code-editor/CodeEditor', () => { + beforeEach(() => { + mockForceParsing.mockClear(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('renders', () => { + const { container } = renderCodeEditor({ + defaultValue: 'content', + }); + expect(container).toHaveTextContent('content'); + }); + + test('Updates value on when user types', async () => { + const { editor } = renderCodeEditor(); + + expect( + editor.getBySelector(CodeEditorSelectors.Content), + ).not.toHaveTextContent('new content'); + + act(() => { + editor.interactions.insertText('new content'); + }); + + expect(editor.getBySelector(CodeEditorSelectors.Content)).toHaveTextContent( + 'new content', + ); + }); + + test('Fold gutter renders when enabled', async () => { + const { editor } = renderCodeEditor({ enableCodeFolding: true }); + expect( + editor.getBySelector(CodeEditorSelectors.FoldGutter), + ).toBeInTheDocument(); + }); + + test('Fold gutter does not render when disabled', async () => { + const { editor } = renderCodeEditor({ enableCodeFolding: false }); + + expect( + editor.queryBySelector(CodeEditorSelectors.FoldGutter), + ).not.toBeInTheDocument(); + }); + + test('Line numbers render when enabled', async () => { + const { editor } = renderCodeEditor({ + defaultValue: 'content', + enableLineNumbers: true, + }); + + expect( + editor.getBySelector(CodeEditorSelectors.GutterElement, { + text: '1', + }), + ).toBeInTheDocument(); + }); + + test('Line numbers do not render when disabled', async () => { + const { editor } = renderCodeEditor({ enableLineNumbers: false }); + + expect( + editor.queryBySelector(CodeEditorSelectors.LineNumbers), + ).not.toBeInTheDocument(); + }); + + test('Clickable URLs render when enabled', async () => { + const { editor } = renderCodeEditor({ + defaultValue: 'https://mongodb.design', + enableClickableUrls: true, + }); + + expect( + editor.getBySelector(CodeEditorSelectors.HyperLink), + ).toBeInTheDocument(); + }); + + test('Clickable URLs do not render when disable', async () => { + const { editor } = renderCodeEditor({ + defaultValue: 'https://mongodb.design', + enableClickableUrls: false, + }); + + expect( + editor.queryBySelector(CodeEditorSelectors.HyperLink), + ).not.toBeInTheDocument(); + }); + + test('Read-only set on editor state when enabled', async () => { + const { editor } = renderCodeEditor({ readOnly: true }); + expect(editor.isReadOnly()).toBe(true); + }); + + test('Read-only not set on editor state when disabled', async () => { + const { editor } = renderCodeEditor({ readOnly: false }); + expect(editor.isReadOnly()).toBe(false); + }); + + test('Line wrapping enabled when enabled', async () => { + const { editor } = renderCodeEditor({ enableLineWrapping: true }); + expect(editor.isLineWrappingEnabled()).toBe(true); + }); + + test('Line wrapping not enabled when disabled', async () => { + const { editor } = renderCodeEditor({ enableLineWrapping: false }); + expect(editor.isLineWrappingEnabled()).toBe(false); + }); + + test('Editor displays placeholder when empty', async () => { + const { editor } = renderCodeEditor({ + placeholder: 'Type your code here...', + }); + expect(editor.getBySelector(CodeEditorSelectors.Content)).toHaveTextContent( + 'Type your code here...', + ); + }); + + test('Editor displays HTMLElement placeholder when empty', async () => { + const placeholderElement = document.createElement('div'); + placeholderElement.textContent = 'Type your code here...'; + const { editor } = renderCodeEditor({ placeholder: placeholderElement }); + expect(editor.getBySelector(CodeEditorSelectors.Content)).toHaveTextContent( + 'Type your code here...', + ); + }); + + test('the forceParsing() method is called when enabled', async () => { + renderCodeEditor({ + forceParsing: true, + defaultValue: 'content', + }); + await waitFor(() => { + expect(mockForceParsing).toHaveBeenCalled(); + }); + }); + + test('the forceParsing() method is not called when disabled', async () => { + const { container } = renderCodeEditor({ + 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 "tab"', async () => { + const { editor } = renderCodeEditor({ + indentUnit: 'tab', + }); + expect(editor.getIndentUnit()).toBe('\t'); + }); + + test('correct indentUnit is set on the editor when indentUnit is "space"', async () => { + const { editor } = renderCodeEditor({ + indentUnit: 'space', + }); + expect(editor.getIndentUnit()).toBe(' '); + }); +}); From 35e442e524af4be3da6bee9b22addb56705d7580 Mon Sep 17 00:00:00 2001 From: Terrence Keane Date: Wed, 29 Oct 2025 13:33:30 -0400 Subject: [PATCH 43/59] Remove unused logs --- packages/code-editor/src/CodeEditor/CodeEditor.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/code-editor/src/CodeEditor/CodeEditor.tsx b/packages/code-editor/src/CodeEditor/CodeEditor.tsx index e7398007a1..33841ed440 100644 --- a/packages/code-editor/src/CodeEditor/CodeEditor.tsx +++ b/packages/code-editor/src/CodeEditor/CodeEditor.tsx @@ -408,9 +408,8 @@ const BaseCodeEditor = forwardRef( if (forceParsingProp) { const Language = modules?.['@codemirror/language']; const docLength = editorViewRef.current?.state.doc.length ?? 0; - console.log('forceParsing true'); + if (Language && Language.forceParsing && docLength > 0) { - console.log('forceParsing called'); Language.forceParsing(editorViewRef.current, docLength, 150); } } From 58cbf7ba850f293d1603162c2cf2ac17dcb3817d Mon Sep 17 00:00:00 2001 From: Terrence Keane Date: Wed, 29 Oct 2025 14:58:14 -0400 Subject: [PATCH 44/59] Refactor tests --- .../CodeEditor.ImperativeHandle.spec.tsx | 112 ++ .../src/CodeEditor/CodeEditor.spec.tsx | 1295 +++-------------- .../src/CodeEditor/CodeEditor.testUtils.tsx | 38 +- .../src/CodeEditor/CodeEditor2.spec.tsx | 201 --- .../src/SearchPanel/SearchPanel.spec.tsx | 458 ++++++ 5 files changed, 786 insertions(+), 1318 deletions(-) create mode 100644 packages/code-editor/src/CodeEditor/CodeEditor.ImperativeHandle.spec.tsx delete mode 100644 packages/code-editor/src/CodeEditor/CodeEditor2.spec.tsx create mode 100644 packages/code-editor/src/SearchPanel/SearchPanel.spec.tsx 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..34782e6f64 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((x: number, y: number) => { + 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,152 @@ 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({ + 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 +270,184 @@ 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); - }); - }); - - 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.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('renders panel when panel is passed as a child', () => { + const PANEL_TEST_ID = 'test-panel'; + const { container } = renderCodeEditor({ + 'data-lgid': 'lg-test-editor', + children: ( + Test Panel Content
+ } + /> + ), + }); + const panelElement = container.querySelector( + `[data-testid="${PANEL_TEST_ID}"]`, ); + expect(panelElement).toBeInTheDocument(); + }); + + test('does not render context menu when right-clicking on panel', () => { + const PANEL_TEST_ID = 'test-panel'; + const { container } = renderCodeEditor({ + 'data-lgid': 'lg-test-editor', + children: ( + Test Panel Content
+ } + /> + ), + }); + const panelElement = container.querySelector( + `[data-testid="${PANEL_TEST_ID}"]`, + ); + expect(panelElement).toBeInTheDocument(); - 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(); + // Right-click on the panel to trigger context menu + userEvent.click(panelElement!, { button: 2 }); - act(() => { - handle.downloadContent(); // No filename provided, uses default - }); + expect( + container.querySelector('[data-lgid="lg-test-editor-context_menu"]'), + ).not.toBeInTheDocument(); + }); - 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 = []; + // Focus the editor by clicking on the content area + const contentElement = editor.getBySelector(CodeEditorSelectors.Content); + userEvent.click(contentElement); - const { editor } = renderCodeEditor({ - defaultValue: 'console.log("Hello World");', - language: LanguageName.javascript, - }); - await editor.waitForEditorView(); - - 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 at the start of the line + const contentElement = editor.getBySelector(CodeEditorSelectors.Content); + userEvent.click(contentElement); - // Focus the editor and position cursor on the indented 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.testUtils.tsx b/packages/code-editor/src/CodeEditor/CodeEditor.testUtils.tsx index bbeb6c047f..25e3a5084f 100644 --- a/packages/code-editor/src/CodeEditor/CodeEditor.testUtils.tsx +++ b/packages/code-editor/src/CodeEditor/CodeEditor.testUtils.tsx @@ -24,10 +24,6 @@ import * as lezerHighlight from '@lezer/highlight'; import * as langCSharp from '@replit/codemirror-lang-csharp'; import { render } from '@testing-library/react'; import * as hyperLink from '@uiw/codemirror-extensions-hyper-link'; -// WASM formatting modules -import * as wasmClangFormat from '@wasm-fmt/clang-format'; -import * as wasmGofmt from '@wasm-fmt/gofmt'; -import * as wasmRuffFmt from '@wasm-fmt/ruff_fmt'; // Import all CodeMirror modules for synchronous testing import * as codemirror from 'codemirror'; import * as prettierParserBabel from 'prettier/parser-babel'; @@ -51,7 +47,7 @@ import * as prettierParserPostcss from 'prettier/parser-postcss'; * Pre-loaded modules for synchronous testing. * Contains all possible CodeMirror modules to avoid async loading in tests. */ -const preLoadedModules: CodeEditorModules = { +const preLoadedModules: Partial = { codemirror, '@codemirror/view': codemirrorView, '@codemirror/state': codemirrorState, @@ -85,22 +81,11 @@ const preLoadedModules: CodeEditorModules = { let editorViewInstance: CodeMirrorView | null = null; let editorHandleInstance: any = null; -/** - * Gets the editor view instance. With preloaded modules, the editor view is available - * immediately after render, so this resolves synchronously. - * Kept as async for backward compatibility with existing tests. - * @returns Promise that resolves to the CodeMirror view instance - * @throws Error if editor view is not available - */ -async function waitForEditorView(): Promise { - 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.', @@ -122,7 +107,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) { @@ -167,7 +152,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) { @@ -202,7 +187,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; } @@ -211,7 +196,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); } @@ -229,7 +214,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(); } @@ -257,7 +242,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; @@ -317,7 +302,6 @@ export const editor = { undo, redo, }, - waitForEditorView, }; /** @@ -331,9 +315,7 @@ export const editor = { export function renderCodeEditor( props: Partial = {}, moduleOverrides?: Partial, - options?: { children?: React.ReactNode }, ) { - const { children } = options || {}; const { container } = render( - {children} - , + />, ); return { container, editor }; diff --git a/packages/code-editor/src/CodeEditor/CodeEditor2.spec.tsx b/packages/code-editor/src/CodeEditor/CodeEditor2.spec.tsx deleted file mode 100644 index bb516dc6f8..0000000000 --- a/packages/code-editor/src/CodeEditor/CodeEditor2.spec.tsx +++ /dev/null @@ -1,201 +0,0 @@ -import React from 'react'; -import { act, waitFor } from '@testing-library/react'; - -import { renderCodeEditor } from './CodeEditor.testUtils'; -import { CodeEditorSelectors } from './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: [], -})); - -describe('packages/code-editor/CodeEditor', () => { - beforeEach(() => { - mockForceParsing.mockClear(); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - test('renders', () => { - const { container } = renderCodeEditor({ - defaultValue: 'content', - }); - expect(container).toHaveTextContent('content'); - }); - - test('Updates value on when user types', async () => { - const { editor } = renderCodeEditor(); - - expect( - editor.getBySelector(CodeEditorSelectors.Content), - ).not.toHaveTextContent('new content'); - - act(() => { - editor.interactions.insertText('new content'); - }); - - expect(editor.getBySelector(CodeEditorSelectors.Content)).toHaveTextContent( - 'new content', - ); - }); - - test('Fold gutter renders when enabled', async () => { - const { editor } = renderCodeEditor({ enableCodeFolding: true }); - expect( - editor.getBySelector(CodeEditorSelectors.FoldGutter), - ).toBeInTheDocument(); - }); - - test('Fold gutter does not render when disabled', async () => { - const { editor } = renderCodeEditor({ enableCodeFolding: false }); - - expect( - editor.queryBySelector(CodeEditorSelectors.FoldGutter), - ).not.toBeInTheDocument(); - }); - - test('Line numbers render when enabled', async () => { - const { editor } = renderCodeEditor({ - defaultValue: 'content', - enableLineNumbers: true, - }); - - expect( - editor.getBySelector(CodeEditorSelectors.GutterElement, { - text: '1', - }), - ).toBeInTheDocument(); - }); - - test('Line numbers do not render when disabled', async () => { - const { editor } = renderCodeEditor({ enableLineNumbers: false }); - - expect( - editor.queryBySelector(CodeEditorSelectors.LineNumbers), - ).not.toBeInTheDocument(); - }); - - test('Clickable URLs render when enabled', async () => { - const { editor } = renderCodeEditor({ - defaultValue: 'https://mongodb.design', - enableClickableUrls: true, - }); - - expect( - editor.getBySelector(CodeEditorSelectors.HyperLink), - ).toBeInTheDocument(); - }); - - test('Clickable URLs do not render when disable', async () => { - const { editor } = renderCodeEditor({ - defaultValue: 'https://mongodb.design', - enableClickableUrls: false, - }); - - expect( - editor.queryBySelector(CodeEditorSelectors.HyperLink), - ).not.toBeInTheDocument(); - }); - - test('Read-only set on editor state when enabled', async () => { - const { editor } = renderCodeEditor({ readOnly: true }); - expect(editor.isReadOnly()).toBe(true); - }); - - test('Read-only not set on editor state when disabled', async () => { - const { editor } = renderCodeEditor({ readOnly: false }); - expect(editor.isReadOnly()).toBe(false); - }); - - test('Line wrapping enabled when enabled', async () => { - const { editor } = renderCodeEditor({ enableLineWrapping: true }); - expect(editor.isLineWrappingEnabled()).toBe(true); - }); - - test('Line wrapping not enabled when disabled', async () => { - const { editor } = renderCodeEditor({ enableLineWrapping: false }); - expect(editor.isLineWrappingEnabled()).toBe(false); - }); - - test('Editor displays placeholder when empty', async () => { - const { editor } = renderCodeEditor({ - placeholder: 'Type your code here...', - }); - expect(editor.getBySelector(CodeEditorSelectors.Content)).toHaveTextContent( - 'Type your code here...', - ); - }); - - test('Editor displays HTMLElement placeholder when empty', async () => { - const placeholderElement = document.createElement('div'); - placeholderElement.textContent = 'Type your code here...'; - const { editor } = renderCodeEditor({ placeholder: placeholderElement }); - expect(editor.getBySelector(CodeEditorSelectors.Content)).toHaveTextContent( - 'Type your code here...', - ); - }); - - test('the forceParsing() method is called when enabled', async () => { - renderCodeEditor({ - forceParsing: true, - defaultValue: 'content', - }); - await waitFor(() => { - expect(mockForceParsing).toHaveBeenCalled(); - }); - }); - - test('the forceParsing() method is not called when disabled', async () => { - const { container } = renderCodeEditor({ - 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 "tab"', async () => { - const { editor } = renderCodeEditor({ - indentUnit: 'tab', - }); - expect(editor.getIndentUnit()).toBe('\t'); - }); - - test('correct indentUnit is set on the editor when indentUnit is "space"', async () => { - const { editor } = renderCodeEditor({ - indentUnit: 'space', - }); - expect(editor.getIndentUnit()).toBe(' '); - }); -}); 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..d0311cbb61 --- /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((x: number, y: number) => { + 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'); + }); + }); +}); From 804309323b797eeee511ae3c1d82ca1b20291258 Mon Sep 17 00:00:00 2001 From: Terrence Keane Date: Wed, 29 Oct 2025 15:28:25 -0400 Subject: [PATCH 45/59] All tests passing --- .../src/CodeEditor/CodeEditor.testUtils.tsx | 69 +------------------ .../hooks/extensions/useExtensions.spec.ts | 7 +- .../extensions/useReadOnlyExtension.spec.ts | 23 ++++--- .../code-editor/src/Panel/Panel.testUtils.tsx | 4 +- .../src/testing/getTestUtils.spec.tsx | 14 +++- .../src/testing/preLoadedModules.ts | 69 +++++++++++++++++++ 6 files changed, 104 insertions(+), 82 deletions(-) create mode 100644 packages/code-editor/src/testing/preLoadedModules.ts diff --git a/packages/code-editor/src/CodeEditor/CodeEditor.testUtils.tsx b/packages/code-editor/src/CodeEditor/CodeEditor.testUtils.tsx index 25e3a5084f..1cfa9db8de 100644 --- a/packages/code-editor/src/CodeEditor/CodeEditor.testUtils.tsx +++ b/packages/code-editor/src/CodeEditor/CodeEditor.testUtils.tsx @@ -1,36 +1,9 @@ import React from 'react'; -import * as codemirrorAutocomplete from '@codemirror/autocomplete'; -import * as codemirrorCommands from '@codemirror/commands'; -// Language-specific imports -import * as langCpp from '@codemirror/lang-cpp'; -import * as langGo from '@codemirror/lang-go'; -import * as langHtml from '@codemirror/lang-html'; -import * as langJava from '@codemirror/lang-java'; -import * as langJavaScript from '@codemirror/lang-javascript'; -import * as langJson from '@codemirror/lang-json'; -import * as langPhp from '@codemirror/lang-php'; -import * as langPython from '@codemirror/lang-python'; -import * as langRust from '@codemirror/lang-rust'; import { indentUnit } from '@codemirror/language'; -import * as codemirrorLanguage from '@codemirror/language'; -import * as legacyModeClike from '@codemirror/legacy-modes/mode/clike'; -import * as legacyModeRuby from '@codemirror/legacy-modes/mode/ruby'; -import * as codemirrorLint from '@codemirror/lint'; -import * as codemirrorSearch from '@codemirror/search'; import { type ChangeSpec } from '@codemirror/state'; -import * as codemirrorState from '@codemirror/state'; -import * as codemirrorView from '@codemirror/view'; -import * as lezerHighlight from '@lezer/highlight'; -import * as langCSharp from '@replit/codemirror-lang-csharp'; import { render } from '@testing-library/react'; -import * as hyperLink from '@uiw/codemirror-extensions-hyper-link'; -// Import all CodeMirror modules for synchronous testing -import * as codemirror from 'codemirror'; -import * as prettierParserBabel from 'prettier/parser-babel'; -import * as prettierParserHtml from 'prettier/parser-html'; -import * as prettierParserTypescript from 'prettier/parser-typescript'; -// Prettier formatting modules -import * as prettierStandalone from 'prettier/standalone'; + +import { preLoadedModules } from '../testing/preLoadedModules'; import { type CodeEditorModules } from './hooks'; import { @@ -40,44 +13,6 @@ import { CodeMirrorView, } from '.'; -import * as langCss from '@codemirror/lang-css'; -import * as prettierParserPostcss from 'prettier/parser-postcss'; - -/** - * Pre-loaded modules for synchronous testing. - * Contains all possible CodeMirror modules to avoid async loading in tests. - */ -const preLoadedModules: Partial = { - codemirror, - '@codemirror/view': codemirrorView, - '@codemirror/state': codemirrorState, - '@codemirror/commands': codemirrorCommands, - '@codemirror/language': codemirrorLanguage, - '@codemirror/lint': codemirrorLint, - '@codemirror/autocomplete': codemirrorAutocomplete, - '@codemirror/search': codemirrorSearch, - '@lezer/highlight': lezerHighlight, - '@uiw/codemirror-extensions-hyper-link': hyperLink, - '@codemirror/lang-cpp': langCpp, - '@replit/codemirror-lang-csharp': langCSharp, - '@codemirror/lang-css': langCss, - '@codemirror/lang-go': langGo, - '@codemirror/lang-html': langHtml, - '@codemirror/lang-java': langJava, - '@codemirror/lang-javascript': langJavaScript, - '@codemirror/lang-json': langJson, - '@codemirror/lang-php': langPhp, - '@codemirror/lang-python': langPython, - '@codemirror/lang-rust': langRust, - '@codemirror/legacy-modes/mode/clike': legacyModeClike, - '@codemirror/legacy-modes/mode/ruby': legacyModeRuby, - 'prettier/standalone': prettierStandalone, - 'prettier/parser-babel': prettierParserBabel, - 'prettier/parser-typescript': prettierParserTypescript, - 'prettier/parser-postcss': prettierParserPostcss, - 'prettier/parser-html': prettierParserHtml, -}; - let editorViewInstance: CodeMirrorView | null = null; let editorHandleInstance: any = null; 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/useReadOnlyExtension.spec.ts b/packages/code-editor/src/CodeEditor/hooks/extensions/useReadOnlyExtension.spec.ts index 95eeb5f809..ba14bbaa60 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); // compartment was created }); }); diff --git a/packages/code-editor/src/Panel/Panel.testUtils.tsx b/packages/code-editor/src/Panel/Panel.testUtils.tsx index 46a4d74d2d..286946136b 100644 --- a/packages/code-editor/src/Panel/Panel.testUtils.tsx +++ b/packages/code-editor/src/Panel/Panel.testUtils.tsx @@ -72,8 +72,8 @@ export function renderPanel(config: RenderPanelConfig = {}) { redo: defaultStubRedo, downloadContent: defaultStubDownloadContent, lgIds: getLgIds(), - undoDepth: 0, - redoDepth: 0, + undoDepth: 1, + redoDepth: 1, baseFontSize: 13 as const, darkMode: false, ...contextConfig, diff --git a/packages/code-editor/src/testing/getTestUtils.spec.tsx b/packages/code-editor/src/testing/getTestUtils.spec.tsx index 0f757e0924..dd629669f5 100644 --- a/packages/code-editor/src/testing/getTestUtils.spec.tsx +++ b/packages/code-editor/src/testing/getTestUtils.spec.tsx @@ -7,6 +7,7 @@ import { act } from '@leafygreen-ui/testing-lib'; import { CodeEditor, CodeEditorProps } from '../CodeEditor'; import { getTestUtils } from './getTestUtils'; +import { preLoadedModules } from './preLoadedModules'; // MutationObserver not supported in JSDOM global.MutationObserver = jest.fn().mockImplementation(() => ({ @@ -16,6 +17,13 @@ global.MutationObserver = jest.fn().mockImplementation(() => ({ 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(), +})); + const DEFAULT_LGID = 'lg-code-editor'; const TestComponent = ({ @@ -25,7 +33,11 @@ const TestComponent = ({ return (

Code Editor

- + {children}
diff --git a/packages/code-editor/src/testing/preLoadedModules.ts b/packages/code-editor/src/testing/preLoadedModules.ts new file mode 100644 index 0000000000..d6b37e669d --- /dev/null +++ b/packages/code-editor/src/testing/preLoadedModules.ts @@ -0,0 +1,69 @@ +import * as codemirrorAutocomplete from '@codemirror/autocomplete'; +import * as codemirrorCommands from '@codemirror/commands'; +// Language-specific imports +import * as langCpp from '@codemirror/lang-cpp'; +import * as langGo from '@codemirror/lang-go'; +import * as langHtml from '@codemirror/lang-html'; +import * as langJava from '@codemirror/lang-java'; +import * as langJavaScript from '@codemirror/lang-javascript'; +import * as langJson from '@codemirror/lang-json'; +import * as langPhp from '@codemirror/lang-php'; +import * as langPython from '@codemirror/lang-python'; +import * as langRust from '@codemirror/lang-rust'; +import * as codemirrorLanguage from '@codemirror/language'; +import * as legacyModeClike from '@codemirror/legacy-modes/mode/clike'; +import * as legacyModeRuby from '@codemirror/legacy-modes/mode/ruby'; +import * as codemirrorLint from '@codemirror/lint'; +import * as codemirrorSearch from '@codemirror/search'; +import * as codemirrorState from '@codemirror/state'; +import * as codemirrorView from '@codemirror/view'; +import * as lezerHighlight from '@lezer/highlight'; +import * as langCSharp from '@replit/codemirror-lang-csharp'; +import * as hyperLink from '@uiw/codemirror-extensions-hyper-link'; +// Import all CodeMirror modules for synchronous testing +import * as codemirror from 'codemirror'; +import * as prettierParserBabel from 'prettier/parser-babel'; +import * as prettierParserHtml from 'prettier/parser-html'; +import * as prettierParserTypescript from 'prettier/parser-typescript'; +// Prettier formatting modules +import * as prettierStandalone from 'prettier/standalone'; + +import { type CodeEditorModules } from '../CodeEditor/hooks'; + +import * as langCss from '@codemirror/lang-css'; +import * as prettierParserPostcss from 'prettier/parser-postcss'; + +/** + * Pre-loaded modules for synchronous testing. + * Contains all possible CodeMirror modules to avoid async loading in tests. + */ +export const preLoadedModules: Partial = { + codemirror, + '@codemirror/view': codemirrorView, + '@codemirror/state': codemirrorState, + '@codemirror/commands': codemirrorCommands, + '@codemirror/language': codemirrorLanguage, + '@codemirror/lint': codemirrorLint, + '@codemirror/autocomplete': codemirrorAutocomplete, + '@codemirror/search': codemirrorSearch, + '@lezer/highlight': lezerHighlight, + '@uiw/codemirror-extensions-hyper-link': hyperLink, + '@codemirror/lang-cpp': langCpp, + '@replit/codemirror-lang-csharp': langCSharp, + '@codemirror/lang-css': langCss, + '@codemirror/lang-go': langGo, + '@codemirror/lang-html': langHtml, + '@codemirror/lang-java': langJava, + '@codemirror/lang-javascript': langJavaScript, + '@codemirror/lang-json': langJson, + '@codemirror/lang-php': langPhp, + '@codemirror/lang-python': langPython, + '@codemirror/lang-rust': langRust, + '@codemirror/legacy-modes/mode/clike': legacyModeClike, + '@codemirror/legacy-modes/mode/ruby': legacyModeRuby, + 'prettier/standalone': prettierStandalone, + 'prettier/parser-babel': prettierParserBabel, + 'prettier/parser-typescript': prettierParserTypescript, + 'prettier/parser-postcss': prettierParserPostcss, + 'prettier/parser-html': prettierParserHtml, +}; From b837759e24c99b1a505e47df918d8f3d367f6768 Mon Sep 17 00:00:00 2001 From: Terrence Keane Date: Wed, 29 Oct 2025 15:30:56 -0400 Subject: [PATCH 46/59] Linting passed --- packages/code-editor/src/CodeEditor/CodeEditor.spec.tsx | 2 +- packages/code-editor/src/SearchPanel/SearchPanel.spec.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/code-editor/src/CodeEditor/CodeEditor.spec.tsx b/packages/code-editor/src/CodeEditor/CodeEditor.spec.tsx index 34782e6f64..564f6f689e 100644 --- a/packages/code-editor/src/CodeEditor/CodeEditor.spec.tsx +++ b/packages/code-editor/src/CodeEditor/CodeEditor.spec.tsx @@ -60,7 +60,7 @@ if (typeof Range !== 'undefined' && !Range.prototype.getClientRects) { // Mock elementFromPoint which is used by CodeMirror for mouse position handling if (!document.elementFromPoint) { - document.elementFromPoint = jest.fn((x: number, y: number) => { + document.elementFromPoint = jest.fn(() => { return document.body; }); } diff --git a/packages/code-editor/src/SearchPanel/SearchPanel.spec.tsx b/packages/code-editor/src/SearchPanel/SearchPanel.spec.tsx index d0311cbb61..182b638e88 100644 --- a/packages/code-editor/src/SearchPanel/SearchPanel.spec.tsx +++ b/packages/code-editor/src/SearchPanel/SearchPanel.spec.tsx @@ -38,7 +38,7 @@ global.IntersectionObserver = jest.fn().mockImplementation(() => ({ // Mock elementFromPoint which is used by CodeMirror for mouse position handling if (!document.elementFromPoint) { - document.elementFromPoint = jest.fn((x: number, y: number) => { + document.elementFromPoint = jest.fn(() => { return document.body; }); } From b557b0fd30ebf8e4db5fb73bceb31bdafa6ee011 Mon Sep 17 00:00:00 2001 From: Terrence Keane Date: Thu, 30 Oct 2025 09:42:09 -0400 Subject: [PATCH 47/59] refactor(code-editor): remove overflow shadow styles and update gutter background color --- .../src/CodeEditor/CodeEditor.styles.ts | 22 ------------------- .../hooks/extensions/useThemeExtension.ts | 7 +++--- 2 files changed, 4 insertions(+), 25 deletions(-) diff --git a/packages/code-editor/src/CodeEditor/CodeEditor.styles.ts b/packages/code-editor/src/CodeEditor/CodeEditor.styles.ts index 08d83606b7..158d058e67 100644 --- a/packages/code-editor/src/CodeEditor/CodeEditor.styles.ts +++ b/packages/code-editor/src/CodeEditor/CodeEditor.styles.ts @@ -5,12 +5,10 @@ import { Theme, } from '@leafygreen-ui/lib'; import { - addOverflowShadow, borderRadius, breakpoints, color, InteractionState, - Side, spacing, transitionDuration, Variant, @@ -104,26 +102,6 @@ export const getEditorStyles = ({ } } `]: copyButtonAppearance === CopyButtonAppearance.Hover, - - // Overflow Shadows (applied via classes for performance) - [css` - &.lg-code-editor-has-top-shadow ${CodeEditorSelectors.Editor} { - ${addOverflowShadow({ - side: Side.Top, - theme, - isInside: true, - })} - } - `]: true, - [css` - &.lg-code-editor-has-bottom-shadow ${CodeEditorSelectors.Editor} { - ${addOverflowShadow({ - side: Side.Bottom, - theme, - isInside: true, - })} - } - `]: true, }, className, ); diff --git a/packages/code-editor/src/CodeEditor/hooks/extensions/useThemeExtension.ts b/packages/code-editor/src/CodeEditor/hooks/extensions/useThemeExtension.ts index 202d2568d8..e7cbbd618a 100644 --- a/packages/code-editor/src/CodeEditor/hooks/extensions/useThemeExtension.ts +++ b/packages/code-editor/src/CodeEditor/hooks/extensions/useThemeExtension.ts @@ -107,6 +107,10 @@ export function useThemeExtension({ }, [CodeEditorSelectors.Gutters]: { + backgroundColor: + color[theme].background[Variant.Primary][ + InteractionState.Default + ], color: color[theme].text[Variant.Secondary][InteractionState.Default], border: 'none', @@ -114,9 +118,6 @@ export function useThemeExtension({ borderBottomLeftRadius: `${borderRadius[300]}px`, fontFamily: fontFamilies.code, fontSize: `${fontSize}px`, - // Forces the gutters to scroll with content to make shadows work - position: 'static !important', - background: 'transparent', }, [`${CodeEditorSelectors.LineNumbers} ${CodeEditorSelectors.GutterElement}`]: From 7361dbfa922146147f358715bc84fd9f4a7e5d75 Mon Sep 17 00:00:00 2001 From: Terrence Keane Date: Thu, 30 Oct 2025 09:43:21 -0400 Subject: [PATCH 48/59] refactor(code-editor): remove unused theme prop from CodeEditor styles and component --- packages/code-editor/src/CodeEditor/CodeEditor.styles.ts | 2 -- packages/code-editor/src/CodeEditor/CodeEditor.tsx | 1 - 2 files changed, 3 deletions(-) diff --git a/packages/code-editor/src/CodeEditor/CodeEditor.styles.ts b/packages/code-editor/src/CodeEditor/CodeEditor.styles.ts index 158d058e67..f09a1e3c25 100644 --- a/packages/code-editor/src/CodeEditor/CodeEditor.styles.ts +++ b/packages/code-editor/src/CodeEditor/CodeEditor.styles.ts @@ -40,7 +40,6 @@ export const getEditorStyles = ({ maxHeight, className, copyButtonAppearance, - theme, }: { width?: string; minWidth?: string; @@ -50,7 +49,6 @@ export const getEditorStyles = ({ maxHeight?: string; className?: string; copyButtonAppearance?: CopyButtonAppearance; - theme: Theme; }) => { return cx( css` diff --git a/packages/code-editor/src/CodeEditor/CodeEditor.tsx b/packages/code-editor/src/CodeEditor/CodeEditor.tsx index 33841ed440..7d0ea2b322 100644 --- a/packages/code-editor/src/CodeEditor/CodeEditor.tsx +++ b/packages/code-editor/src/CodeEditor/CodeEditor.tsx @@ -555,7 +555,6 @@ const BaseCodeEditor = forwardRef( maxHeight, className, copyButtonAppearance, - theme, })} data-lgid={lgIds.root} {...rest} From bf5d952edb76de3b607c83b31eeb5a6590747e17 Mon Sep 17 00:00:00 2001 From: Terrence Keane Date: Thu, 30 Oct 2025 09:52:21 -0400 Subject: [PATCH 49/59] Fix interaction tests by using preloaded modules to prevent loading --- .../code-editor/src/CodeEditor.interactions.stories.tsx | 6 ++++++ 1 file changed, 6 insertions(+) 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 ( {} }]} /> ); From 173f4b6a7ab9dfa7f8f2dc03848a6cfc08f83e67 Mon Sep 17 00:00:00 2001 From: Terrence Keane Date: Thu, 30 Oct 2025 10:31:13 -0400 Subject: [PATCH 50/59] changeset --- .changeset/bumpy-turtles-start.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 .changeset/bumpy-turtles-start.md diff --git a/.changeset/bumpy-turtles-start.md b/.changeset/bumpy-turtles-start.md new file mode 100644 index 0000000000..110166848a --- /dev/null +++ b/.changeset/bumpy-turtles-start.md @@ -0,0 +1,22 @@ +--- +'@leafygreen-ui/code-editor': minor +--- + +Fixes a number 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 From 0bb0a6b30de712d5aaebcfe291a6da454a79ddd5 Mon Sep 17 00:00:00 2001 From: Terrence Keane Date: Thu, 30 Oct 2025 11:04:50 -0400 Subject: [PATCH 51/59] Misc comments and cleanup --- .../src/CodeEditor/CodeEditor.styles.ts | 9 +- .../code-editor/src/CodeEditor/CodeEditor.tsx | 84 ++----------------- .../hooks/extensions/useReadOnlyExtension.ts | 2 + .../hooks/extensions/useThemeExtension.ts | 2 +- .../hooks/extensions/useTooltipExtension.ts | 2 +- .../code-editor/src/Panel/Panel.styles.ts | 2 +- 6 files changed, 17 insertions(+), 84 deletions(-) diff --git a/packages/code-editor/src/CodeEditor/CodeEditor.styles.ts b/packages/code-editor/src/CodeEditor/CodeEditor.styles.ts index f09a1e3c25..b3df7c79a2 100644 --- a/packages/code-editor/src/CodeEditor/CodeEditor.styles.ts +++ b/packages/code-editor/src/CodeEditor/CodeEditor.styles.ts @@ -51,9 +51,6 @@ export const getEditorStyles = ({ copyButtonAppearance?: CopyButtonAppearance; }) => { return cx( - css` - position: relative; - `, { // Dimensions [css` @@ -101,6 +98,9 @@ export const getEditorStyles = ({ } `]: copyButtonAppearance === CopyButtonAppearance.Hover, }, + css` + position: relative; + `, className, ); }; @@ -187,9 +187,6 @@ export const getLoaderStyles = ({ export const getLoadingTextStyles = (theme: Theme) => { return css` color: ${color[theme].text[Variant.Secondary][InteractionState.Default]}; - display: flex; - align-items: center; - gap: ${spacing[50]}px; `; }; diff --git a/packages/code-editor/src/CodeEditor/CodeEditor.tsx b/packages/code-editor/src/CodeEditor/CodeEditor.tsx index 7d0ea2b322..32834008a8 100644 --- a/packages/code-editor/src/CodeEditor/CodeEditor.tsx +++ b/packages/code-editor/src/CodeEditor/CodeEditor.tsx @@ -1,7 +1,6 @@ import React, { forwardRef, useCallback, - useEffect, useImperativeHandle, useLayoutEffect, useRef, @@ -32,7 +31,6 @@ import { import { CodeEditorHandle, type CodeEditorProps, - CodeEditorSelectors, CodeEditorSubcomponentProperty, type CodeMirrorExtension, CopyButtonAppearance, @@ -91,9 +89,6 @@ const BaseCodeEditor = forwardRef( const editorViewRef = useRef(null); const [undoDepth, setUndoDepth] = useState(0); const [redoDepth, setRedoDepth] = useState(0); - // Use refs to track shadow state without causing re-renders during scroll - const hasTopShadowRef = useRef(false); - const hasBottomShadowRef = useRef(false); const { modules, isLoading } = useModules(props); @@ -324,7 +319,10 @@ const BaseCodeEditor = forwardRef( // eslint-disable-next-line react-hooks/exhaustive-deps }, [modules]); - // Configure/update extensions whenever relevant props change + /** + * 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']; @@ -400,7 +398,11 @@ const BaseCodeEditor = forwardRef( ]), }); - // Wait for next frame to ensure extensions are rendered before hiding loading overlay + /** + * 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); }); @@ -432,74 +434,6 @@ const BaseCodeEditor = forwardRef( onChangeProp, ]); - // Update shadow state and apply to DOM immediately without re-render - const updateScrollShadows = useCallback( - (scrollerElement: HTMLElement, containerElement: HTMLElement) => { - const hasTop = scrollerElement.scrollTop > 0; - const hasBottom = - Math.abs( - scrollerElement.scrollHeight - - scrollerElement.clientHeight - - scrollerElement.scrollTop, - ) >= 1; - - // Update refs (no re-render) - hasTopShadowRef.current = hasTop; - hasBottomShadowRef.current = hasBottom; - - // Apply classes directly to DOM (no re-render) - if (hasTop) { - containerElement.classList.add('lg-code-editor-has-top-shadow'); - } else { - containerElement.classList.remove('lg-code-editor-has-top-shadow'); - } - - if (hasBottom) { - containerElement.classList.add('lg-code-editor-has-bottom-shadow'); - } else { - containerElement.classList.remove('lg-code-editor-has-bottom-shadow'); - } - }, - [], - ); - - // Handle scroll shadows for overflow indication - useEffect(() => { - if (!editorContainerRef.current || !editorViewRef.current) { - return; - } - - const containerElement = editorContainerRef.current; - const scrollerElement = containerElement.querySelector( - CodeEditorSelectors.Scroller, // CM Element that handles scrolling - ) as HTMLElement | null; - - if (!scrollerElement) { - return; - } - - const handleScroll = () => { - updateScrollShadows(scrollerElement, containerElement); - }; - - // Initial check - updateScrollShadows(scrollerElement, containerElement); - - // Listen for scroll events - scrollerElement.addEventListener('scroll', handleScroll, { - passive: true, - }); - - // Also check on resize in case content changes - const resizeObserver = new ResizeObserver(handleScroll); - resizeObserver.observe(scrollerElement); - - return () => { - scrollerElement.removeEventListener('scroll', handleScroll); - resizeObserver.disconnect(); - }; - }, [modules, updateScrollShadows]); - useImperativeHandle(forwardedRef, () => ({ getEditorViewInstance: () => editorViewRef.current, getContents, diff --git a/packages/code-editor/src/CodeEditor/hooks/extensions/useReadOnlyExtension.ts b/packages/code-editor/src/CodeEditor/hooks/extensions/useReadOnlyExtension.ts index c2c0c6df93..118e1fa32b 100644 --- a/packages/code-editor/src/CodeEditor/hooks/extensions/useReadOnlyExtension.ts +++ b/packages/code-editor/src/CodeEditor/hooks/extensions/useReadOnlyExtension.ts @@ -38,7 +38,9 @@ export function useReadOnlyExtension({ 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 e7cbbd618a..ed1141c70a 100644 --- a/packages/code-editor/src/CodeEditor/hooks/extensions/useThemeExtension.ts +++ b/packages/code-editor/src/CodeEditor/hooks/extensions/useThemeExtension.ts @@ -20,6 +20,7 @@ import { type CodeEditorModules } from '../moduleLoaders.types'; import { useExtension } from './useExtension'; +// Exported so the an estimate 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]; @@ -82,7 +83,6 @@ export function useThemeExtension({ borderTopLeftRadius: hasPanel ? 0 : `${borderRadius[300]}px`, borderTopRightRadius: hasPanel ? 0 : `${borderRadius[300]}px`, color: color[theme].text[Variant.Primary][InteractionState.Default], - overflow: 'hidden', }, [`&${CodeEditorSelectors.Focused}`]: { diff --git a/packages/code-editor/src/CodeEditor/hooks/extensions/useTooltipExtension.ts b/packages/code-editor/src/CodeEditor/hooks/extensions/useTooltipExtension.ts index 12e1dfc839..81a03bf646 100644 --- a/packages/code-editor/src/CodeEditor/hooks/extensions/useTooltipExtension.ts +++ b/packages/code-editor/src/CodeEditor/hooks/extensions/useTooltipExtension.ts @@ -89,7 +89,7 @@ export function useTooltipExtension({ { /** * Decreasing but if consumers decide to use this for live linting, we might want to revert this so it - * show errors too fast. + * doesn't show errors too fast. */ delay: 100, }, diff --git a/packages/code-editor/src/Panel/Panel.styles.ts b/packages/code-editor/src/Panel/Panel.styles.ts index ef40a84bb6..22040c4ad0 100644 --- a/packages/code-editor/src/Panel/Panel.styles.ts +++ b/packages/code-editor/src/Panel/Panel.styles.ts @@ -8,7 +8,7 @@ import { Variant, } from '@leafygreen-ui/tokens'; -export const PANEL_HEIGHT = 36; +export const PANEL_HEIGHT = 36; // exported to style absolutely positioned loading element const MODAL_HEIGHT = 354; const getBasePanelStyles = (theme: Theme) => css` From 0d1b9df8d31e585cbc7efe128b6bed43da012055 Mon Sep 17 00:00:00 2001 From: Terrence Keane Date: Thu, 30 Oct 2025 11:57:26 -0400 Subject: [PATCH 52/59] copilot review fixes --- packages/code-editor/src/CodeEditor/CodeEditor.styles.ts | 2 +- .../CodeEditor/hooks/extensions/useCodeFoldingExtension.tsx | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/code-editor/src/CodeEditor/CodeEditor.styles.ts b/packages/code-editor/src/CodeEditor/CodeEditor.styles.ts index b3df7c79a2..db20fd8ecd 100644 --- a/packages/code-editor/src/CodeEditor/CodeEditor.styles.ts +++ b/packages/code-editor/src/CodeEditor/CodeEditor.styles.ts @@ -110,7 +110,7 @@ function getHeight( baseFontSize: CodeEditorProps['baseFontSize'], ) { const borders = 2; - const fontSize = baseFontSize ? baseFontSize : 13; + const fontSize = baseFontSize ?? 13; const numOfLinesForCalculation = numOfLines === 0 ? 1 : numOfLines; return ( diff --git a/packages/code-editor/src/CodeEditor/hooks/extensions/useCodeFoldingExtension.tsx b/packages/code-editor/src/CodeEditor/hooks/extensions/useCodeFoldingExtension.tsx index 8fc918b70c..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,9 @@ export function useCodeFoldingExtension({ glyph="ChevronRight" size={CUSTOM_ICON_SIZE} className={css` - /** Design indicated that the close icon seemed a bit unaligned at 4px, so */ + /** + * 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; `} /> From 46a216e75bb54d38216852dc908df2fb7ee44de7 Mon Sep 17 00:00:00 2001 From: Terrence Keane Date: Thu, 30 Oct 2025 12:46:01 -0400 Subject: [PATCH 53/59] Fix copy tooltip in darkmode --- .../src/CodeEditorCopyButton/CodeEditorCopyButton.tsx | 1 + 1 file changed, 1 insertion(+) 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={ Date: Thu, 30 Oct 2025 13:34:58 -0400 Subject: [PATCH 54/59] CR feedback --- .../CodeEditor/hooks/extensions/useReadOnlyExtension.spec.ts | 2 +- .../src/CodeEditor/hooks/extensions/useThemeExtension.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 ba14bbaa60..79851dacf9 100644 --- a/packages/code-editor/src/CodeEditor/hooks/extensions/useReadOnlyExtension.spec.ts +++ b/packages/code-editor/src/CodeEditor/hooks/extensions/useReadOnlyExtension.spec.ts @@ -33,6 +33,6 @@ describe('useReadOnlyExtension', () => { }), ); const current = result.current as any; - expect(current.inner.length).toBeGreaterThan(0); // compartment was created + expect(current.inner.length).toBeGreaterThan(0); // CodeMirrorcompartment was created }); }); diff --git a/packages/code-editor/src/CodeEditor/hooks/extensions/useThemeExtension.ts b/packages/code-editor/src/CodeEditor/hooks/extensions/useThemeExtension.ts index ed1141c70a..a34f4c3eb6 100644 --- a/packages/code-editor/src/CodeEditor/hooks/extensions/useThemeExtension.ts +++ b/packages/code-editor/src/CodeEditor/hooks/extensions/useThemeExtension.ts @@ -20,7 +20,7 @@ import { type CodeEditorModules } from '../moduleLoaders.types'; import { useExtension } from './useExtension'; -// Exported so the an estimate height can be calculated for the editor while it's loading +// 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]; From c83e112a40ead4f55935a9e7af83f0773f297010 Mon Sep 17 00:00:00 2001 From: Terrence Keane Date: Thu, 30 Oct 2025 16:15:49 -0400 Subject: [PATCH 55/59] Use test utils to get panel --- .../src/CodeEditor/CodeEditor.spec.tsx | 48 ++++++------------- 1 file changed, 14 insertions(+), 34 deletions(-) diff --git a/packages/code-editor/src/CodeEditor/CodeEditor.spec.tsx b/packages/code-editor/src/CodeEditor/CodeEditor.spec.tsx index 564f6f689e..de081e822d 100644 --- a/packages/code-editor/src/CodeEditor/CodeEditor.spec.tsx +++ b/packages/code-editor/src/CodeEditor/CodeEditor.spec.tsx @@ -213,6 +213,7 @@ describe('packages/code-editor/CodeEditor', () => { 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 @@ -321,48 +322,27 @@ describe('packages/code-editor/CodeEditor', () => { }); test('renders panel when panel is passed as a child', () => { - const PANEL_TEST_ID = 'test-panel'; - const { container } = renderCodeEditor({ - 'data-lgid': 'lg-test-editor', - children: ( - Test Panel Content
- } - /> - ), + const lgId = 'lg-test-editor'; + renderCodeEditor({ + 'data-lgid': lgId, + children: , }); - const panelElement = container.querySelector( - `[data-testid="${PANEL_TEST_ID}"]`, - ); + const utils = getTestUtils(lgId).getPanelUtils(); + const panelElement = utils.getPanelElement(); expect(panelElement).toBeInTheDocument(); }); test('does not render context menu when right-clicking on panel', () => { - const PANEL_TEST_ID = 'test-panel'; - const { container } = renderCodeEditor({ - 'data-lgid': 'lg-test-editor', - children: ( - Test Panel Content
- } - /> - ), + const lgId = 'lg-test-editor'; + renderCodeEditor({ + 'data-lgid': lgId, + children: , }); - const panelElement = container.querySelector( - `[data-testid="${PANEL_TEST_ID}"]`, - ); + const utils = getTestUtils(lgId).getPanelUtils(); + const panelElement = utils.getPanelElement(); expect(panelElement).toBeInTheDocument(); - - // Right-click on the panel to trigger context menu userEvent.click(panelElement!, { button: 2 }); - - expect( - container.querySelector('[data-lgid="lg-test-editor-context_menu"]'), - ).not.toBeInTheDocument(); + expect(utils.querySecondaryMenu()).not.toBeInTheDocument(); }); test('Pressing ESC key unfocuses the editor', async () => { From ef0b1632f99309d75da81b178b6ff07d0456719c Mon Sep 17 00:00:00 2001 From: Terrence Keane Date: Thu, 30 Oct 2025 16:37:21 -0400 Subject: [PATCH 56/59] Update changeset to major release --- .changeset/bumpy-turtles-start.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.changeset/bumpy-turtles-start.md b/.changeset/bumpy-turtles-start.md index 110166848a..2935315268 100644 --- a/.changeset/bumpy-turtles-start.md +++ b/.changeset/bumpy-turtles-start.md @@ -1,8 +1,10 @@ --- -'@leafygreen-ui/code-editor': minor +'@leafygreen-ui/code-editor': major --- -Fixes a number of bugs. These include: +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 From 9b08fa946baba7f27ad3bd934eb80da48180f2d6 Mon Sep 17 00:00:00 2001 From: Terrence Keane Date: Thu, 30 Oct 2025 17:07:28 -0400 Subject: [PATCH 57/59] CR feedback --- packages/code-editor/src/CodeEditor/CodeEditor.tsx | 1 + packages/code-editor/src/CodeEditor/CodeEditorContext.tsx | 6 ++++++ packages/code-editor/src/Panel/Panel.styles.ts | 8 +++++++- packages/code-editor/src/Panel/Panel.tsx | 6 +++++- 4 files changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/code-editor/src/CodeEditor/CodeEditor.tsx b/packages/code-editor/src/CodeEditor/CodeEditor.tsx index 32834008a8..ca509592e1 100644 --- a/packages/code-editor/src/CodeEditor/CodeEditor.tsx +++ b/packages/code-editor/src/CodeEditor/CodeEditor.tsx @@ -461,6 +461,7 @@ const BaseCodeEditor = forwardRef( readOnly, darkMode, baseFontSize, + isLoading: isLoadingProp || isLoading || !extensionsInitialized, }; const numOfLines = ( diff --git a/packages/code-editor/src/CodeEditor/CodeEditorContext.tsx b/packages/code-editor/src/CodeEditor/CodeEditorContext.tsx index ffb5961126..a5cc9e26b7 100644 --- a/packages/code-editor/src/CodeEditor/CodeEditorContext.tsx +++ b/packages/code-editor/src/CodeEditor/CodeEditorContext.tsx @@ -74,6 +74,11 @@ export interface CodeEditorContextValue * 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 @@ -96,6 +101,7 @@ const defaultContextValue: CodeEditorContextValue = { redoDepth: 0, baseFontSize: 13, darkMode: false, + isLoading: false, }; const CodeEditorContext = diff --git a/packages/code-editor/src/Panel/Panel.styles.ts b/packages/code-editor/src/Panel/Panel.styles.ts index 22040c4ad0..9ee9562a01 100644 --- a/packages/code-editor/src/Panel/Panel.styles.ts +++ b/packages/code-editor/src/Panel/Panel.styles.ts @@ -8,7 +8,13 @@ import { Variant, } from '@leafygreen-ui/tokens'; -export const PANEL_HEIGHT = 36; // exported to style absolutely positioned loading element +/** + * 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; const getBasePanelStyles = (theme: Theme) => css` diff --git a/packages/code-editor/src/Panel/Panel.tsx b/packages/code-editor/src/Panel/Panel.tsx index 8f8aeea03b..106b0d1cb4 100644 --- a/packages/code-editor/src/Panel/Panel.tsx +++ b/packages/code-editor/src/Panel/Panel.tsx @@ -70,6 +70,7 @@ export function Panel({ downloadContent, formatCode, getContents, + isLoading, lgIds, maxWidth, minWidth, @@ -155,10 +156,11 @@ export function Panel({ justify="middle" trigger={ @@ -173,6 +175,7 @@ export function Panel({ getContentsToCopy={getContents ?? (() => '')} onCopy={onCopyClick} data-lgid={lgIds.panelCopyButton} + disabled={isLoading} /> )} {showSecondaryMenuButton && ( @@ -182,6 +185,7 @@ export function Panel({ darkMode={theme === 'dark'} aria-label="Show more actions" data-lgid={lgIds.panelSecondaryMenuButton} + disabled={isLoading} > From e23fb5160539aea8651a87b6eeff2fc993cafb91 Mon Sep 17 00:00:00 2001 From: Terrence Keane Date: Fri, 31 Oct 2025 08:13:24 -0400 Subject: [PATCH 58/59] Fix build --- packages/code-editor/src/Panel/Panel.testUtils.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/code-editor/src/Panel/Panel.testUtils.tsx b/packages/code-editor/src/Panel/Panel.testUtils.tsx index 286946136b..491733d73b 100644 --- a/packages/code-editor/src/Panel/Panel.testUtils.tsx +++ b/packages/code-editor/src/Panel/Panel.testUtils.tsx @@ -76,6 +76,7 @@ export function renderPanel(config: RenderPanelConfig = {}) { redoDepth: 1, baseFontSize: 13 as const, darkMode: false, + isLoading: false, ...contextConfig, }; From abf02e473d3715e7e94defab12f1c8ec139381b7 Mon Sep 17 00:00:00 2001 From: Terrence Keane Date: Fri, 31 Oct 2025 08:18:11 -0400 Subject: [PATCH 59/59] Fix darkMode again on prettify button --- packages/code-editor/src/Panel/Panel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/code-editor/src/Panel/Panel.tsx b/packages/code-editor/src/Panel/Panel.tsx index 106b0d1cb4..1554fc87b4 100644 --- a/packages/code-editor/src/Panel/Panel.tsx +++ b/packages/code-editor/src/Panel/Panel.tsx @@ -156,7 +156,7 @@ export function Panel({ justify="middle" trigger={