Repository: Sage/carbon (ts)

Issue:
Extending TextEditor to call `onChange` on style changes and expose `ref`
### Description

After `<TextEditor>` was migrated to using `lexical`, the following behaviours are no longer supported:
- `onChange` is no longer triggered after style changes (bold, italic, ...etc.). This is a crucial feature because not every consumer of `<TextEditor>` uses the Save button and thus not logging the latest styles when the user changes the style without typing anything new
- `ref` is no longer exposed. This is needed when integrating with form libraries like React Hook Form, for use cases like field focus on error

### Suggested solution

These behaviours should be restored to facilitate migration to using the latest `<TextEditor>`.

### Demo URL

_No response_

### Alternatives

No alternatives possible

### Additional context

_No response_

### Confidentiality

- [x] I confirm there is no confidential or commercially sensitive information included.

Pull request:
feat(text-editor): add programmatic focus support for the text editor
fixes #7320 

### Proposed behaviour

<!--
A clear and concise description of what changes this PR makes. If applicable, include any UI screenshots to help explain your request. If you are a Sage contributor, please DO NOT share any commercially sensitive information, such as UI screenshots or code from Sage products.
-->

`TextEditor` now accepts a `forwardRef`, and an imperative handle has been introduced which provides consumers with access to a focus method only.

Tests have been added to establish a baseline for `onChange` behaviour being triggered by toolbar buttons.

### Current behaviour

<!--
A clear and concise description of the behaviour before this change. If applicable, include any UI screenshots to help explain your request. If you are a Sage contributor, please DO NOT share any commercially sensitive information, such as UI screenshots or code from Sage products.
-->

`TextEditor` no longer supports a `forwardRef` since its conversion to Lexical, but some consumers rely on passing a ref for programmatic focus control.

Also clicks on toolbar buttons result in `onChange` being triggered, due  previous changes, however there are no tests added to test against this behaviour.

### Checklist

<!-- Each PR should include the following -->

- [x] Commits follow our style guide
- [x] Related issues linked in commit messages if required
- [ ] Screenshots are included in the PR if useful
- [ ] All themes are supported if required
- [x] Unit tests added or updated if required
- [ ] Playwright automation tests added or updated if required
- [x] Storybook added or updated if required
- [ ] Translations added or updated (including creating or amending translation keys table in storybook) if required
- [ ] Typescript `d.ts` file added or updated if required
- [ ] Related docs have been updated if required

#### QA

- [ ] Tested in provided StackBlitz sandbox/Storybook
- [ ] Add new Playwright test coverage if required
- [ ] Carbon implementation matches Design System/designs
- [ ] UI Tests GitHub check reviewed if required

### Additional context

<!-- Add any other context or links about the pull request here. -->

### Testing instructions

<!--
How can a reviewer test this PR?

If this PR addresses a pre-existing bug, please include a link to a sandbox that reproduces the original bug. A starter template has been provided to help you do this:
<https://stackblitz.com/fork/github/Parsium/carbon-starter>
-->

The new programmatic focus story on `TextEditor` should be sufficient enough to test that the new imperative handle does indeed result in the TextEditor being focused


Patch:
diff --git a/src/components/text-editor/index.ts b/src/components/text-editor/index.ts
index f368cb351..b646c9f9e 100644
--- a/src/components/text-editor/index.ts
+++ b/src/components/text-editor/index.ts
@@ -1,6 +1,7 @@
 export { default } from "./text-editor.component";
 export { createEmpty, createFromHTML } from "./utils";
 export type {
+  TextEditorHandle,
   TextEditorProps,
   EditorFormattedValues,
 } from "./text-editor.component";
diff --git a/src/components/text-editor/text-editor.component.tsx b/src/components/text-editor/text-editor.component.tsx
index 5ed8e95d7..da42874bf 100644
--- a/src/components/text-editor/text-editor.component.tsx
+++ b/src/components/text-editor/text-editor.component.tsx
@@ -13,9 +13,11 @@ import { $getRoot, EditorState, LexicalEditor } from "lexical";
 import React, {
   useCallback,
   useEffect,
+  useImperativeHandle,
   useMemo,
   useRef,
   useState,
+  forwardRef,
 } from "react";
 import { MarginProps } from "styled-system";
 import { SerializeLexical, validateUrl } from "./__internal__/helpers";
@@ -53,6 +55,11 @@ import HintText from "../../__internal__/hint-text";
 import { filterStyledSystemMarginProps } from "../../style/utils";
 import tagComponent, { TagProps } from "../../__internal__/utils/helpers/tags";
 
+export interface TextEditorHandle {
+  /** Programmatically focus on the text editor. */
+  focus: () => void;
+}
+
 export type EditorFormattedValues = SaveCallbackProps;
 
 export interface TextEditorProps extends MarginProps, TagProps {
@@ -105,252 +112,276 @@ export interface TextEditorProps extends MarginProps, TagProps {
 
 let deprecateOptionalWarnTriggered = false;
 
-export const TextEditor = ({
-  characterLimit = 3000,
-  error,
-  footer,
-  header,
-  inputHint,
-  isOptional = false,
-  labelText,
-  namespace = COMPONENT_PREFIX,
-  onBlur,
-  onCancel,
-  onChange,
-  onFocus,
-  onLinkAdded,
-  onSave,
-  placeholder,
-  previews = [],
-  readOnly = false,
-  required = false,
-  rows,
-  warning,
-  value,
-  ...rest
-}: TextEditorProps) => {
-  if (!deprecateOptionalWarnTriggered && isOptional) {
-    deprecateOptionalWarnTriggered = true;
-    Logger.deprecate(
-      "`isOptional` is deprecated in TextEditor and support will soon be removed. If the value of this component is not required, use the `required` prop and set it to false instead.",
+export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
+  (
+    {
+      characterLimit = 3000,
+      error,
+      footer,
+      header,
+      inputHint,
+      isOptional = false,
+      labelText,
+      namespace = COMPONENT_PREFIX,
+      onBlur,
+      onCancel,
+      onChange,
+      onFocus,
+      onLinkAdded,
+      onSave,
+      placeholder,
+      previews = [],
+      readOnly = false,
+      required = false,
+      rows,
+      warning,
+      value,
+      ...rest
+    },
+    ref,
+  ) => {
+    if (!deprecateOptionalWarnTriggered && isOptional) {
+      deprecateOptionalWarnTriggered = true;
+      Logger.deprecate(
+        "`isOptional` is deprecated in TextEditor and support will soon be removed. If the value of this component is not required, use the `required` prop and set it to false instead.",
+      );
+    }
+    const editorRef = useRef<LexicalEditor | undefined>(undefined);
+    const locale = useLocale();
+    const [characterLimitWarning, setCharacterLimitWarning] = useState<
+      string | undefined
+    >(undefined);
+    const hasWarningOrError = Boolean(
+      error || characterLimitWarning || warning,
     );
-  }
-  const editorRef = useRef<LexicalEditor | undefined>(undefined);
-  const locale = useLocale();
-  const [characterLimitWarning, setCharacterLimitWarning] = useState<
-    string | undefined
-  >(undefined);
-  const hasWarningOrError = Boolean(error || characterLimitWarning || warning);
-  const contentEditorRef = useRef<HTMLDivElement>(null);
-  const [isFocused, setIsFocused] = useState<boolean>(false);
-
-  useEffect(() => {
-    const editorElement = contentEditorRef?.current;
+    const contentEditorRef = useRef<HTMLDivElement>(null);
+    const [isFocused, setIsFocused] = useState<boolean>(false);
 
-    const handleFocus = () => {
-      setIsFocused(true);
-    };
-    const handleBlur = () => {
-      setIsFocused(false);
-    };
+    useImperativeHandle<TextEditorHandle, TextEditorHandle>(
+      ref,
+      () => ({
+        focus() {
+          contentEditorRef.current?.focus();
+        },
+      }),
+      [],
+    );
 
-    editorElement?.addEventListener("focus", handleFocus);
-    editorElement?.addEventListener("blur", handleBlur);
+    useEffect(() => {
+      const editorElement = contentEditorRef?.current;
 
-    const cleanup = () => {
-      editorElement?.removeEventListener("focus", handleFocus);
-      editorElement?.removeEventListener("blur", handleBlur);
-    };
+      const handleFocus = () => {
+        setIsFocused(true);
+      };
+      const handleBlur = () => {
+        setIsFocused(false);
+      };
 
-    return cleanup;
-  }, [contentEditorRef]);
+      editorElement?.addEventListener("focus", handleFocus);
+      editorElement?.addEventListener("blur", handleBlur);
 
-  const [cancelTrigger, setCancelTrigger] = useState<boolean>(false);
+      const cleanup = () => {
+        editorElement?.removeEventListener("focus", handleFocus);
+        editorElement?.removeEventListener("blur", handleBlur);
+      };
 
-  const initialConfig = useMemo(() => {
-    return {
-      namespace,
-      nodes: markdownNodes,
-      onError: console.error,
-      theme,
-      editorState: value,
-      editable: !readOnly,
-    };
-  }, [namespace, readOnly, value]);
+      return cleanup;
+    }, [contentEditorRef]);
 
-  // OnChangePlugin is tested separately
-  /* istanbul ignore next */
-  const handleChange = useCallback(
-    (newState: EditorState) => {
-      const currentTextContent = newState.read(() =>
-        $getRoot().getTextContent(),
-      );
+    const [cancelTrigger, setCancelTrigger] = useState<boolean>(false);
 
-      if (onChange) {
-        const formattedValues = editorRef.current
-          ? SerializeLexical(editorRef.current)
-          : {};
-        onChange?.(currentTextContent, formattedValues);
-      }
+    const initialConfig = useMemo(() => {
+      return {
+        namespace,
+        nodes: markdownNodes,
+        onError: console.error,
+        theme,
+        editorState: value,
+        editable: !readOnly,
+      };
+    }, [namespace, readOnly, value]);
 
-      // If the character limit is set, check if the limit has been exceeded
-      if (characterLimit > 0) {
-        const currentDiff = characterLimit - currentTextContent.length;
-        // If the character limit has been exceeded, show the character limit warning
-        setCharacterLimitWarning(
-          currentDiff < 0
-            ? locale.textEditor.characterLimit(Math.abs(currentDiff))
-            : undefined,
+    // OnChangePlugin is tested separately
+    /* istanbul ignore next */
+    const handleChange = useCallback(
+      (newState: EditorState) => {
+        const currentTextContent = newState.read(() =>
+          $getRoot().getTextContent(),
         );
-      }
-    },
-    [characterLimit, locale.textEditor, onChange],
-  );
 
-  const handleCancel = useCallback(() => {
-    /* istanbul ignore next */
-    const isEditable = editorRef.current?.isEditable() || false;
-    /* istanbul ignore if */
-    if (!isEditable) return;
+        if (onChange) {
+          const formattedValues = editorRef.current
+            ? SerializeLexical(editorRef.current)
+            : {};
+          onChange?.(currentTextContent, formattedValues);
+        }
 
-    /* istanbul ignore else */
-    if (onCancel) {
-      setCancelTrigger((prev) => !prev);
-      onCancel();
-    }
-  }, [onCancel]);
+        // If the character limit is set, check if the limit has been exceeded
+        if (characterLimit > 0) {
+          const currentDiff = characterLimit - currentTextContent.length;
+          // If the character limit has been exceeded, show the character limit warning
+          setCharacterLimitWarning(
+            currentDiff < 0
+              ? locale.textEditor.characterLimit(Math.abs(currentDiff))
+              : undefined,
+          );
+        }
+      },
+      [characterLimit, locale.textEditor, onChange],
+    );
 
-  // Reset the value of the editor when the cancel trigger is updated (implements reset on cancel)
-  useEffect(() => {
-    const safeValue = value || createEmpty();
+    const handleCancel = useCallback(() => {
+      /* istanbul ignore next */
+      const isEditable = editorRef.current?.isEditable() || false;
+      /* istanbul ignore if */
+      if (!isEditable) return;
 
-    /* istanbul ignore else */
-    if (editorRef.current) {
-      const newEditorState = editorRef.current.parseEditorState(safeValue);
-      editorRef.current.setEditorState(newEditorState);
-    }
-  }, [cancelTrigger, value]);
+      /* istanbul ignore else */
+      if (onCancel) {
+        setCancelTrigger((prev) => !prev);
+        onCancel();
+      }
+    }, [onCancel]);
 
-  const toolbarProps = useMemo(
-    () => ({
-      namespace,
-      onCancel: onCancel ? handleCancel : undefined,
-      onSave,
-    }),
-    [handleCancel, namespace, onCancel, onSave],
-  );
+    // Reset the value of the editor when the cancel trigger is updated (implements reset on cancel)
+    useEffect(() => {
+      const safeValue = value || createEmpty();
 
-  return (
-    <StyledTextEditorWrapper
-      data-role={`${namespace}-editor-wrapper`}
-      onBlur={(ev) => {
-        if (!ev.currentTarget.contains(ev.relatedTarget)) {
-          onBlur?.(ev);
-        }
-      }}
-      onFocus={(ev) => {
-        if (!ev.currentTarget.contains(ev.relatedTarget)) {
-          onFocus?.(ev);
-        }
-      }}
-      {...filterStyledSystemMarginProps(rest)}
-      {...tagComponent("text-editor", rest)}
-    >
-      <TextEditorContext.Provider value={{ onLinkAdded }}>
-        <Label
-          isRequired={required}
-          optional={isOptional}
-          labelId={`${namespace}-label`}
-        >
-          {labelText}
-        </Label>
+      /* istanbul ignore else */
+      if (editorRef.current) {
+        const newEditorState = editorRef.current.parseEditorState(safeValue);
+        editorRef.current.setEditorState(newEditorState);
+      }
+    }, [cancelTrigger, value]);
 
-        {inputHint && (
-          <HintText
-            id={`${namespace}-input-hint`}
-            marginBottom="var(--spacing100)"
-          >
-            {inputHint}
-          </HintText>
-        )}
-        <LexicalComposer initialConfig={initialConfig}>
-          <EditorRefPlugin editorRef={editorRef} />
-          <StyledWrapper
-            data-role={`${namespace}-wrapper`}
-            error={error || undefined}
-            namespace={namespace}
-            warning={characterLimitWarning || warning || undefined}
+    const toolbarProps = useMemo(
+      () => ({
+        namespace,
+        onCancel: onCancel ? handleCancel : undefined,
+        onSave,
+      }),
+      [handleCancel, namespace, onCancel, onSave],
+    );
+
+    return (
+      <StyledTextEditorWrapper
+        data-role={`${namespace}-editor-wrapper`}
+        onBlur={(ev) => {
+          if (!ev.currentTarget.contains(ev.relatedTarget)) {
+            onBlur?.(ev);
+          }
+        }}
+        onFocus={(ev) => {
+          if (!ev.currentTarget.contains(ev.relatedTarget)) {
+            onFocus?.(ev);
+          }
+        }}
+        {...filterStyledSystemMarginProps(rest)}
+        {...tagComponent("text-editor", rest)}
+      >
+        <TextEditorContext.Provider value={{ onLinkAdded }}>
+          <Label
+            isRequired={required}
+            optional={isOptional}
+            labelId={`${namespace}-label`}
           >
-            {(error || characterLimitWarning || warning) && (
-              <StyledValidationMessage
-                error={error}
-                id={`${namespace}-validation-message`}
-                data-role={`${namespace}-validation-message`}
-              >
-                {error || characterLimitWarning || warning}
-              </StyledValidationMessage>
-            )}
-            <StyledEditorToolbarWrapper
-              data-role={`${namespace}-editor-toolbar-wrapper`}
-              id={`${namespace}-editor-toolbar-wrapper`}
-              focused={isFocused}
-              hasWarningOrError={hasWarningOrError}
+            {labelText}
+          </Label>
+
+          {inputHint && (
+            <HintText
+              id={`${namespace}-input-hint`}
+              marginBottom="var(--spacing100)"
             >
-              {header && (
-                <StyledHeaderWrapper data-role={`${namespace}-header-wrapper`}>
-                  {header}
-                </StyledHeaderWrapper>
-              )}
-              {!readOnly && (
-                <ToolbarPlugin hasHeader={Boolean(header)} {...toolbarProps} />
+              {inputHint}
+            </HintText>
+          )}
+          <LexicalComposer initialConfig={initialConfig}>
+            <EditorRefPlugin editorRef={editorRef} />
+            <StyledWrapper
+              data-role={`${namespace}-wrapper`}
+              error={error || undefined}
+              namespace={namespace}
+              warning={characterLimitWarning || warning || undefined}
+            >
+              {(error || characterLimitWarning || warning) && (
+                <StyledValidationMessage
+                  error={error}
+                  id={`${namespace}-validation-message`}
+                  data-role={`${namespace}-validation-message`}
+                >
+                  {error || characterLimitWarning || warning}
+                </StyledValidationMessage>
               )}
-              <StyledTextEditor data-role={`${namespace}-editor`}>
-                <RichTextPlugin
-                  contentEditable={
-                    <ContentEditor
-                      ref={contentEditorRef}
-                      inputHint={inputHint}
-                      namespace={namespace}
-                      previews={previews}
-                      rows={rows}
-                      readOnly={readOnly}
-                      required={required}
-                      error={!!error}
-                      warning={!!warning || !!characterLimitWarning}
-                    />
-                  }
-                  placeholder={
-                    <Placeholder namespace={namespace} text={placeholder} />
-                  }
-                  ErrorBoundary={LexicalErrorBoundary}
+              <StyledEditorToolbarWrapper
+                data-role={`${namespace}-editor-toolbar-wrapper`}
+                id={`${namespace}-editor-toolbar-wrapper`}
+                focused={isFocused}
+                hasWarningOrError={hasWarningOrError}
+              >
+                {header && (
+                  <StyledHeaderWrapper
+                    data-role={`${namespace}-header-wrapper`}
+                  >
+                    {header}
+                  </StyledHeaderWrapper>
+                )}
+                {!readOnly && (
+                  <ToolbarPlugin
+                    hasHeader={Boolean(header)}
+                    {...toolbarProps}
+                  />
+                )}
+                <StyledTextEditor data-role={`${namespace}-editor`}>
+                  <RichTextPlugin
+                    contentEditable={
+                      <ContentEditor
+                        ref={contentEditorRef}
+                        inputHint={inputHint}
+                        namespace={namespace}
+                        previews={previews}
+                        rows={rows}
+                        readOnly={readOnly}
+                        required={required}
+                        error={!!error}
+                        warning={!!warning || !!characterLimitWarning}
+                      />
+                    }
+                    placeholder={
+                      <Placeholder namespace={namespace} text={placeholder} />
+                    }
+                    ErrorBoundary={LexicalErrorBoundary}
+                  />
+                  <ListPlugin />
+                  <HistoryPlugin />
+                  <MarkdownShortcutPlugin />
+                  <OnChangePlugin onChange={handleChange} />
+                  <LinkPlugin validateUrl={validateUrl} />
+                  <ClickableLinkPlugin newTab />
+                  <AutoLinkerPlugin />
+                </StyledTextEditor>
+                {footer && (
+                  <StyledFooterWrapper
+                    data-role={`${namespace}-footer-wrapper`}
+                  >
+                    {footer}
+                  </StyledFooterWrapper>
+                )}
+                <LinkMonitorPlugin />
+              </StyledEditorToolbarWrapper>
+
+              {characterLimit > 0 && !readOnly && (
+                <CharacterCounterPlugin
+                  maxChars={characterLimit}
+                  namespace={namespace}
                 />
-                <ListPlugin />
-                <HistoryPlugin />
-                <MarkdownShortcutPlugin />
-                <OnChangePlugin onChange={handleChange} />
-                <LinkPlugin validateUrl={validateUrl} />
-                <ClickableLinkPlugin newTab />
-                <AutoLinkerPlugin />
-              </StyledTextEditor>
-              {footer && (
-                <StyledFooterWrapper data-role={`${namespace}-footer-wrapper`}>
-                  {footer}
-                </StyledFooterWrapper>
               )}
-              <LinkMonitorPlugin />
-            </StyledEditorToolbarWrapper>
-
-            {characterLimit > 0 && !readOnly && (
-              <CharacterCounterPlugin
-                maxChars={characterLimit}
-                namespace={namespace}
-              />
-            )}
-          </StyledWrapper>
-        </LexicalComposer>
-      </TextEditorContext.Provider>
-    </StyledTextEditorWrapper>
-  );
-};
+            </StyledWrapper>
+          </LexicalComposer>
+        </TextEditorContext.Provider>
+      </StyledTextEditorWrapper>
+    );
+  },
+);
 
 export default TextEditor;
diff --git a/src/components/text-editor/text-editor.mdx b/src/components/text-editor/text-editor.mdx
index 0eb765e81..a89a46ebe 100644
--- a/src/components/text-editor/text-editor.mdx
+++ b/src/components/text-editor/text-editor.mdx
@@ -44,6 +44,17 @@ All instances of the Text Editor should have a label that describes the purpose
 
 <Canvas of={TextEditorStories.Default} />
 
+### Focusing the Text Editor Programmatically
+
+```javascript
+import { TextEditorHandle } from "carbon-react/lib/components/text-editor";
+```
+
+The `TextEditorHandle` type provides an imperative handle for programmatic control over the Text Editor. 
+Using a `ref`, you can access its `focus()` method to set focus on the Text Editor as needed.
+
+<Canvas of={TextEditorStories.ProgrammaticFocus} />
+
 ### With Header
 
 The Text Editor accepts a header property, which can be any valid React node and is rendered above the toolbar, providing space for custom elements such as titles, controls, or contextual information.
diff --git a/src/components/text-editor/text-editor.stories.tsx b/src/components/text-editor/text-editor.stories.tsx
index 5907cd3bc..76be47d1b 100644
--- a/src/components/text-editor/text-editor.stories.tsx
+++ b/src/components/text-editor/text-editor.stories.tsx
@@ -13,6 +13,7 @@ import Link from "../link";
 import enGB from "../../locales/en-gb";
 
 import TextEditor, {
+  TextEditorHandle,
   createEmpty,
   createFromHTML,
   EditorFormattedValues,
@@ -42,6 +43,25 @@ export const Default: Story = () => {
 };
 Default.storyName = "Default";
 
+export const ProgrammaticFocus = () => {
+  const editorRef = useRef<TextEditorHandle>(null);
+
+  return (
+    <>
+      <Button mb="30px" onClick={() => editorRef.current?.focus()}>
+        Focus the editor
+      </Button>
+
+      <TextEditor
+        ref={editorRef}
+        namespace="storybook-default"
+        labelText="Text Editor"
+      />
+    </>
+  );
+};
+ProgrammaticFocus.storyName = "Focusing the Text Editor Programmatically";
+
 export const Header: Story = () => {
   return (
     <TextEditor


Test patch:
diff --git a/src/components/text-editor/__internal__/plugins/Toolbar/toolbar.test.tsx b/src/components/text-editor/__internal__/plugins/Toolbar/toolbar.test.tsx
index ba3fb9a61..5c48ef128 100644
--- a/src/components/text-editor/__internal__/plugins/Toolbar/toolbar.test.tsx
+++ b/src/components/text-editor/__internal__/plugins/Toolbar/toolbar.test.tsx
@@ -11,6 +11,7 @@ import React from "react";
 
 import userEvent from "@testing-library/user-event";
 import { ToolbarPlugin } from "..";
+import TextEditor from "../../../text-editor.component";
 
 function headlessEditor() {
   const editor = createHeadlessEditor({
@@ -180,6 +181,22 @@ describe("Events", () => {
     expect(dispatchSpy).toHaveBeenCalledWith(FORMAT_TEXT_COMMAND, "bold");
   });
 
+  /** Have to assert that the onChange mock count has increased by 1 due to the editor attempting to repeatedly create update listeners when the
+   * tests are run, causing the onChange to be fired during initial render. */
+  it("dispatches an 'onChange' event when the bold button is clicked within the TextEditor", async () => {
+    const user = userEvent.setup();
+    const mockOnChange = jest.fn();
+
+    render(<TextEditor labelText="foo" onChange={mockOnChange} />);
+
+    const callCountBefore = mockOnChange.mock.calls.length;
+
+    const boldButton = screen.getByRole("button", { name: "Bold" });
+    await user.click(boldButton);
+
+    expect(mockOnChange).toHaveBeenCalledTimes(callCountBefore + 1);
+  });
+
   /** Using the mocked toolbar, test that clicking the italic button fires the correct event */
   it("dispatches the 'italic' event when the italic button is clicked", async () => {
     const user = userEvent.setup();
@@ -209,6 +226,22 @@ describe("Events", () => {
     expect(dispatchSpy).toHaveBeenCalledWith(FORMAT_TEXT_COMMAND, "italic");
   });
 
+  /** Have to assert that the onChange mock count has increased by 1 due to the editor attempting to repeatedly create update listeners when the
+   * tests are run, causing the onChange to be fired during initial render. */
+  it("dispatches an 'onChange' event when the italic button is clicked within the TextEditor", async () => {
+    const user = userEvent.setup();
+    const mockOnChange = jest.fn();
+
+    render(<TextEditor labelText="foo" onChange={mockOnChange} />);
+
+    const callCountBefore = mockOnChange.mock.calls.length;
+
+    const italicButton = screen.getByRole("button", { name: "Italic" });
+    await user.click(italicButton);
+
+    expect(mockOnChange).toHaveBeenCalledTimes(callCountBefore + 1);
+  });
+
   /** Using the mocked toolbar, test that clicking the ordered list button fires the correct event */
   it("dispatches the 'ordered list' event when the ordered list button is clicked", async () => {
     const user = userEvent.setup();
@@ -238,6 +271,24 @@ describe("Events", () => {
     expect(dispatchSpy).toHaveBeenCalledWith(FORMAT_TEXT_COMMAND, "number");
   });
 
+  /** Have to assert that the onChange mock count has increased by 1 due to the editor attempting to repeatedly create update listeners when the
+   * tests are run, causing the onChange to be fired during initial render. */
+  it("dispatches an 'onChange' event when the ordered list button is clicked within the TextEditor", async () => {
+    const user = userEvent.setup();
+    const mockOnChange = jest.fn();
+
+    render(<TextEditor labelText="foo" onChange={mockOnChange} />);
+
+    const callCountBefore = mockOnChange.mock.calls.length;
+
+    const orderedListButton = screen.getByRole("button", {
+      name: "Ordered list",
+    });
+    await user.click(orderedListButton);
+
+    expect(mockOnChange).toHaveBeenCalledTimes(callCountBefore + 1);
+  });
+
   /** Using the mocked toolbar, test that clicking the unordered list button fires the correct event */
   it("dispatches the 'unordered list' event when the unordered list button is clicked", async () => {
     const user = userEvent.setup();
@@ -266,4 +317,22 @@ describe("Events", () => {
     expect(dispatchSpy).toHaveBeenCalledTimes(1);
     expect(dispatchSpy).toHaveBeenCalledWith(FORMAT_TEXT_COMMAND, "bullet");
   });
+
+  /** Have to assert that the onChange mock count has increased by 1 due to the editor attempting to repeatedly create update listeners when the
+   * tests are run, causing the onChange to be fired during initial render. */
+  it("dispatches an 'onChange' event when the unordered ordered list button is clicked within the TextEditor", async () => {
+    const user = userEvent.setup();
+    const mockOnChange = jest.fn();
+
+    render(<TextEditor labelText="foo" onChange={mockOnChange} />);
+
+    const callCountBefore = mockOnChange.mock.calls.length;
+
+    const unorderedListButton = screen.getByRole("button", {
+      name: "Unordered list",
+    });
+    await user.click(unorderedListButton);
+
+    expect(mockOnChange).toHaveBeenCalledTimes(callCountBefore + 1);
+  });
 });
diff --git a/src/components/text-editor/text-editor.test.tsx b/src/components/text-editor/text-editor.test.tsx
index 0b7f04604..f32207dbd 100644
--- a/src/components/text-editor/text-editor.test.tsx
+++ b/src/components/text-editor/text-editor.test.tsx
@@ -1,9 +1,9 @@
 import { render, screen, within } from "@testing-library/react";
 import userEvent from "@testing-library/user-event";
 
-import React from "react";
+import React, { act, createRef } from "react";
 
-import TextEditor, { createEmpty, createFromHTML } from ".";
+import TextEditor, { TextEditorHandle, createEmpty, createFromHTML } from ".";
 import { COMPONENT_PREFIX } from "./__internal__/constants";
 
 import Logger from "../../__internal__/utils/logger";
@@ -389,6 +389,19 @@ test("serialisation of editor", async () => {
   expect(mockSave).toHaveBeenCalledTimes(1);
 });
 
+test("editor is focused when the focus method is invoked via imperative handle", () => {
+  const editorRef = createRef<TextEditorHandle>();
+
+  render(<TextEditor labelText="Text Editor" ref={editorRef} />);
+
+  act(() => {
+    editorRef.current?.focus();
+  });
+
+  const editor = screen.getByRole("textbox");
+  expect(editor).toHaveFocus();
+});
+
 test("valid data is parsed when HTML is passed into the createFromHTML function", async () => {
   const html = `<p dir="ltr"><span style="white-space: pre-wrap;">This is a HTML example.</span></p><ol><li value="1"><span style="white-space: pre-wrap;">Look, it has lists!</span></li></ol>`;
   const value = createFromHTML(html);