From 7a5247add142839003507e536cf1ff3299e6527f Mon Sep 17 00:00:00 2001 From: Stephen Lee Date: Wed, 8 Oct 2025 17:33:13 -0700 Subject: [PATCH 1/8] refactor(codemods): modal-v20 does not remove initialFocus prop and instead adds guidance comment --- .../tests/filter-packages.output.jsx | 8 +-- .../modal-v20/tests/handle-aliases.output.jsx | 11 ++-- .../tests/remove-initialFocus.input.jsx | 50 ------------------- .../tests/remove-initialFocus.output.jsx | 47 ----------------- .../modal-v20/tests/transform.spec.ts | 3 -- .../src/codemods/modal-v20/transform.ts | 27 +++++----- 6 files changed, 24 insertions(+), 122 deletions(-) delete mode 100644 tools/codemods/src/codemods/modal-v20/tests/remove-initialFocus.input.jsx delete mode 100644 tools/codemods/src/codemods/modal-v20/tests/remove-initialFocus.output.jsx diff --git a/tools/codemods/src/codemods/modal-v20/tests/filter-packages.output.jsx b/tools/codemods/src/codemods/modal-v20/tests/filter-packages.output.jsx index 371ca88743..d5135fcb8b 100644 --- a/tools/codemods/src/codemods/modal-v20/tests/filter-packages.output.jsx +++ b/tools/codemods/src/codemods/modal-v20/tests/filter-packages.output.jsx @@ -9,21 +9,23 @@ export const App = () => { return ( <> - {/* TODO: Please specify autoFocus prop on the element that should receive initial focus. Alternatively, you may rely on the default focus behavior which will focus the first focusable element in the children. */} - - {/* TODO: Please specify autoFocus prop on the element that should receive initial focus. Alternatively, you may rely on the default focus behavior which will focus the first focusable element in the children. */} + {/* Note: The initialFocus prop now supports React refs in addition to selector strings. Consider using a ref for better type safety. */} diff --git a/tools/codemods/src/codemods/modal-v20/tests/handle-aliases.output.jsx b/tools/codemods/src/codemods/modal-v20/tests/handle-aliases.output.jsx index 045206a1bf..d27a241d10 100644 --- a/tools/codemods/src/codemods/modal-v20/tests/handle-aliases.output.jsx +++ b/tools/codemods/src/codemods/modal-v20/tests/handle-aliases.output.jsx @@ -9,30 +9,33 @@ export const App = () => { return ( <> - {/* TODO: Please specify autoFocus prop on the element that should receive initial focus. Alternatively, you may rely on the default focus behavior which will focus the first focusable element in the children. */} - - {/* TODO: Please specify autoFocus prop on the element that should receive initial focus. Alternatively, you may rely on the default focus behavior which will focus the first focusable element in the children. */} + {/* Note: The initialFocus prop now supports React refs in addition to selector strings. Consider using a ref for better type safety. */} - {/* TODO: Please specify autoFocus prop on the element that should receive initial focus. Alternatively, you may rely on the default focus behavior which will focus the first focusable element in the children. */} + {/* Note: The initialFocus prop now supports React refs in addition to selector strings. Consider using a ref for better type safety. */} diff --git a/tools/codemods/src/codemods/modal-v20/tests/remove-initialFocus.input.jsx b/tools/codemods/src/codemods/modal-v20/tests/remove-initialFocus.input.jsx deleted file mode 100644 index 7309b03db4..0000000000 --- a/tools/codemods/src/codemods/modal-v20/tests/remove-initialFocus.input.jsx +++ /dev/null @@ -1,50 +0,0 @@ -import React from 'react'; - -import Modal from '@leafygreen-ui/modal'; -import ConfirmationModal from '@leafygreen-ui/confirmation-modal'; -import MarketingModal from '@leafygreen-ui/marketing-modal'; - -export const App = () => { - const [open, setOpen] = useState(false); - - return ( - <> - -

Modal title

-

Modal content

- -
- - -

Are you sure you want to proceed?

- -
- - -

Limited time offer!

- -
- - {/* Modal without initialFocus should not be affected */} - -

Simple Modal

-

No initial focus specified

-
- - ); -}; diff --git a/tools/codemods/src/codemods/modal-v20/tests/remove-initialFocus.output.jsx b/tools/codemods/src/codemods/modal-v20/tests/remove-initialFocus.output.jsx deleted file mode 100644 index e90b45cd7e..0000000000 --- a/tools/codemods/src/codemods/modal-v20/tests/remove-initialFocus.output.jsx +++ /dev/null @@ -1,47 +0,0 @@ -import React from 'react'; - -import Modal from '@leafygreen-ui/modal'; -import ConfirmationModal from '@leafygreen-ui/confirmation-modal'; -import MarketingModal from '@leafygreen-ui/marketing-modal'; - -export const App = () => { - const [open, setOpen] = useState(false); - - return ( - <> - {/* TODO: Please specify autoFocus prop on the element that should receive initial focus. Alternatively, you may rely on the default focus behavior which will focus the first focusable element in the children. */} - -

Modal title

-

Modal content

- -
- {/* TODO: Please specify autoFocus prop on the element that should receive initial focus. Alternatively, you may rely on the default focus behavior which will focus the first focusable element in the children. */} - -

Are you sure you want to proceed?

- -
- {/* TODO: Please specify autoFocus prop on the element that should receive initial focus. Alternatively, you may rely on the default focus behavior which will focus the first focusable element in the children. */} - -

Limited time offer!

- -
- {/* Modal without initialFocus should not be affected */} - -

Simple Modal

-

No initial focus specified

-
- - ); -}; diff --git a/tools/codemods/src/codemods/modal-v20/tests/transform.spec.ts b/tools/codemods/src/codemods/modal-v20/tests/transform.spec.ts index 88c9a7b3e6..a28771de6b 100644 --- a/tools/codemods/src/codemods/modal-v20/tests/transform.spec.ts +++ b/tools/codemods/src/codemods/modal-v20/tests/transform.spec.ts @@ -6,9 +6,6 @@ const tests = [ { name: 'rename-className-props', }, - { - name: 'remove-initialFocus', - }, { name: 'handle-aliases', }, diff --git a/tools/codemods/src/codemods/modal-v20/transform.ts b/tools/codemods/src/codemods/modal-v20/transform.ts index ca691e828a..7a7da4db7b 100644 --- a/tools/codemods/src/codemods/modal-v20/transform.ts +++ b/tools/codemods/src/codemods/modal-v20/transform.ts @@ -5,10 +5,7 @@ import { LGPackage } from '../../types'; import { getImportSpecifiersForDeclaration } from '../../utils/imports'; import { getJSXAttributes } from '../../utils/jsx'; import { insertJSXComment } from '../../utils/jsx'; -import { - removeJSXAttributes, - replaceJSXAttributes, -} from '../../utils/transformations'; +import { replaceJSXAttributes } from '../../utils/transformations'; const lgPackageComponentMap: Partial> = { [LGPackage.Modal]: 'Modal', @@ -35,11 +32,15 @@ const defaultPackages: Array = [ * - `@leafygreen-ui/confirmation-modal` * - `@leafygreen-ui/marketing-modal` * - * 3. Removes `initialFocus` prop and adds guidance comment for Modal components in the following packages: + * 3. Adds guidance comment for `initialFocus` prop usage for Modal components in the following packages: * - `@leafygreen-ui/modal` * - `@leafygreen-ui/confirmation-modal` * - `@leafygreen-ui/marketing-modal` * + * Note: The `initialFocus` prop was temporarily removed in @leafygreen-ui/modal@20.0.0 but has been + * restored in @leafygreen-ui/modal@20.2.0 with enhanced functionality. The codemod now preserves the prop + * and adds a recommendation to use React refs instead of selector strings for better type safety. + * * @param file the file to transform * @param jscodeshiftOptions an object containing at least a reference to the jscodeshift library * @param options an object containing options to pass to the transform function @@ -148,7 +149,10 @@ export default function transformer( }); /** - * Step 3: Remove initialFocus prop and add guidance comment + * Step 3: Add guidance comment for initialFocus prop (no longer removed in v20.2+) + * + * Note: As of v20.2, the initialFocus prop is available again with enhanced functionality. + * We now recommend using refs instead of selector strings, but both are supported. */ packagesToCheck.forEach(packageName => { const componentsToTransform = packageComponentsMap.get(packageName)!; @@ -159,7 +163,7 @@ export default function transformer( if (elements.length === 0) return; elements.forEach(element => { - // Check if initialFocus prop exists before trying to remove it + // Check if initialFocus prop exists before adding guidance comment const initialFocusAttributes = getJSXAttributes( j, element, @@ -167,19 +171,12 @@ export default function transformer( ); if (initialFocusAttributes.length > 0) { - // Add guidance comment before removing the prop insertJSXComment( j, element, - 'TODO: Please specify autoFocus prop on the element that should receive initial focus. Alternatively, you may rely on the default focus behavior which will focus the first focusable element in the children.', + 'Note: The initialFocus prop now supports React refs in addition to selector strings. Consider using a ref for better type safety.', 'before', ); - - removeJSXAttributes({ - j, - element, - propName: 'initialFocus', - }); } }); }); From f4a00b8b45f848b8c855abd07f0e01ce294bdc01 Mon Sep 17 00:00:00 2001 From: Stephen Lee Date: Wed, 8 Oct 2025 17:33:26 -0700 Subject: [PATCH 2/8] docs(codemods): modal-v20 docs --- tools/codemods/README.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tools/codemods/README.md b/tools/codemods/README.md index 41c37b5950..6762ae2e17 100644 --- a/tools/codemods/README.md +++ b/tools/codemods/README.md @@ -100,7 +100,9 @@ This codemod does the following: 1. Renames `className` prop to `backdropClassName` 2. Renames `contentClassName` prop to `className` -3. Removes `initialFocus` prop and adds guidance comment +3. Adds guidance comment for `initialFocus` prop usage + + **Note**: The `initialFocus` prop was temporarily removed in Modal v20.0.0 but has been restored in v20.2+ with enhanced functionality. The codemod now preserves the prop and adds a recommendation to use refs instead of selector strings for better type safety. ```shell pnpm lg codemod modal-v20 @@ -134,32 +136,34 @@ import ConfirmationModal from '@leafygreen-ui/confirmation-modal';
``` -**After**: +**After** (as of Modal v20.2+): ```tsx import Modal from '@leafygreen-ui/modal'; import ConfirmationModal from '@leafygreen-ui/confirmation-modal'; { - /* TODO: Please specify autoFocus prop on the element that should receive initial focus. Alternatively, you may rely on the default focus behavior which will focus the first focusable element in the children. */ + /* Note: The initialFocus prop now supports React refs in addition to selector strings. Consider using a ref for better type safety. */ } ; { - /* TODO: Please specify autoFocus prop on the element that should receive initial focus. Alternatively, you may rely on the default focus behavior which will focus the first focusable element in the children. */ + /* Note: The initialFocus prop now supports React refs in addition to selector strings. Consider using a ref for better type safety. */ } From 84288dd1292e6da4758a673aeaac06eb7f4a67ab Mon Sep 17 00:00:00 2001 From: Stephen Lee Date: Wed, 8 Oct 2025 21:11:30 -0700 Subject: [PATCH 3/8] chore(codemods): changeset --- .changeset/codemods-modal-v20-update.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/codemods-modal-v20-update.md diff --git a/.changeset/codemods-modal-v20-update.md b/.changeset/codemods-modal-v20-update.md new file mode 100644 index 0000000000..3bb3e1b517 --- /dev/null +++ b/.changeset/codemods-modal-v20-update.md @@ -0,0 +1,7 @@ +--- +'@lg-tools/codemods': minor +--- + +[LG-5608](https://jira.mongodb.org/browse/LG-5608) + +Update `modal-v20` codemod to no longer remove the `initialFocus` prop. Instead, it adds a recommendation comment, suggesting migration to refs for better type safety. From c9ed32c088ea03bb33acfebd55c39fb5bf8222c4 Mon Sep 17 00:00:00 2001 From: Stephen Lee Date: Wed, 8 Oct 2025 21:12:50 -0700 Subject: [PATCH 4/8] feat(modal): add back initialFocus prop with additional support --- packages/modal/src/Modal/Modal.spec.tsx | 131 ++++++++-- packages/modal/src/Modal/Modal.types.ts | 44 ++++ packages/modal/src/Modal/ModalView.tsx | 5 +- .../src/utils/focusModalChildElement.spec.ts | 229 ++++++++++++++---- .../modal/src/utils/focusModalChildElement.ts | 40 ++- 5 files changed, 379 insertions(+), 70 deletions(-) diff --git a/packages/modal/src/Modal/Modal.spec.tsx b/packages/modal/src/Modal/Modal.spec.tsx index 1d64eeb3b1..214599e98a 100644 --- a/packages/modal/src/Modal/Modal.spec.tsx +++ b/packages/modal/src/Modal/Modal.spec.tsx @@ -10,6 +10,7 @@ import { axe } from 'jest-axe'; import { Option, OptionGroup, Select } from '@leafygreen-ui/select'; import { getTestUtils } from '../utils/getTestUtils'; +import { Modal } from '..'; import ModalView from '..'; const modalContent = 'Modal Content'; @@ -188,29 +189,119 @@ describe('packages/modal', () => { expect(modal).toHaveAttribute('open'); }); - test('supports autofocus on child elements', () => { - const { getByTestId } = render( - - {/* eslint-disable-next-line jsx-a11y/no-autofocus */} - - - , - ); + describe('initialFocus prop', () => { + test('focuses element specified by string selector', () => { + const { getByTestId } = render( + + + + , + ); + + const secondaryButton = getByTestId('secondary-button'); + expect(secondaryButton).toHaveFocus(); + }); - const input = getByTestId('auto-focus-input'); - expect(input).toHaveFocus(); - }); + test('focuses element specified by ref', () => { + const TestComponent = () => { + const buttonRef = React.useRef(null); - test('focuses first focusable element when no autoFocus is set', () => { - const { getByRole } = render( - - - - , - ); + return ( + + + + + ); + }; + + const { getByTestId } = render(); + + const secondaryButton = getByTestId('secondary-button'); + expect(secondaryButton).toHaveFocus(); + }); - const submitButton = getByRole('button', { name: 'Submit' }); - expect(submitButton).toHaveFocus(); + test('does not focus any element when initialFocus is null', () => { + const { getByTestId } = render( + + + + , + ); + + const button = getByTestId('button'); + const input = getByTestId('input'); + + // Neither should have focus + expect(button).not.toHaveFocus(); + expect(input).not.toHaveFocus(); + }); + + test('focuses first focusable element when initialFocus is "auto"', () => { + const { getByTestId } = render( + + + + , + ); + + const firstButton = getByTestId('first-button'); + expect(firstButton).toHaveFocus(); + }); + + test('initialFocus selector takes priority over autoFocus attribute', () => { + const { getByTestId } = render( + + {/* eslint-disable-next-line jsx-a11y/no-autofocus */} + + + , + ); + + const targetButton = getByTestId('target-button'); + expect(targetButton).toHaveFocus(); + }); + + test('initialFocus ref takes priority over autoFocus attribute', () => { + const TestComponent = () => { + const buttonRef = React.useRef(null); + + return ( + + {/* eslint-disable-next-line jsx-a11y/no-autofocus */} + + + + ); + }; + + const { getByTestId } = render(); + + const targetButton = getByTestId('target-button'); + expect(targetButton).toHaveFocus(); + }); + + test('falls back to autoFocus when initialFocus selector is not found', () => { + const { getByTestId } = render( + + {/* eslint-disable-next-line jsx-a11y/no-autofocus */} + + + , + ); + + const input = getByTestId('auto-input'); + expect(input).toHaveFocus(); + }); }); test('deprecated backdropClassName still works', () => { diff --git a/packages/modal/src/Modal/Modal.types.ts b/packages/modal/src/Modal/Modal.types.ts index e9d182631a..589eb17255 100644 --- a/packages/modal/src/Modal/Modal.types.ts +++ b/packages/modal/src/Modal/Modal.types.ts @@ -46,6 +46,50 @@ export interface ModalProps */ shouldClose?: () => boolean; + /** + * Specifies which element should receive focus when the modal opens. + * + * **Options:** + * - `"auto"`: Automatically focuses the first focusable element in the modal + * - `string`: CSS selector passed to `querySelector()` to specify an element + * - `React.RefObject`: Reference to the element that should receive focus + * - `null`: Disables automatic focus management. Use sparingly - disabling focus management may create accessibility issues + * + * **Priority order:** + * 1. If `initialFocus` is a selector or ref, that element will be focused + * 2. If any child element has the `autoFocus` attribute, that element will be focused + * 3. If `initialFocus` is `"auto"` and no child element has the `autoFocus` attribute, the first focusable element will be focused + * 4. If `initialFocus` is `null`, no automatic focus will occur + * + * @default "auto" + * + * @example + * // Relying on `autoFocus` attribute + * + * + * + * + * @example + * // Using a ref + * const submitRef = useRef(null); + * + * + * + * + * @example + * // Using a selector + * + * + * + * + * @example + * // Disabling automatic focus + * + * + * + */ + initialFocus?: 'auto' | string | React.RefObject | null; + /** * @deprecated Use CSS `::backdrop` pseudo-element instead. This prop will be removed in a future version. * diff --git a/packages/modal/src/Modal/ModalView.tsx b/packages/modal/src/Modal/ModalView.tsx index ee5eca27d1..d562ffb2f7 100644 --- a/packages/modal/src/Modal/ModalView.tsx +++ b/packages/modal/src/Modal/ModalView.tsx @@ -39,6 +39,7 @@ const ModalView = React.forwardRef( closeIconColor = CloseIconColor.Default, darkMode: darkModeProp, id: idProp, + initialFocus = 'auto', children, className, backdropClassName, @@ -72,11 +73,11 @@ const ModalView = React.forwardRef( if (open && !dialogEl.open) { dialogEl.showModal(); - focusModalChildElement(dialogEl); + focusModalChildElement(dialogEl, initialFocus); } else { dialogEl.close(); } - }, [dialogEl, open]); + }, [dialogEl, open, initialFocus]); const allowedSize = Object.values(ModalSize).includes(sizeProp); const size = allowedSize ? sizeProp : ModalSize.Default; diff --git a/packages/modal/src/utils/focusModalChildElement.spec.ts b/packages/modal/src/utils/focusModalChildElement.spec.ts index e2308467bc..a59361f6af 100644 --- a/packages/modal/src/utils/focusModalChildElement.spec.ts +++ b/packages/modal/src/utils/focusModalChildElement.spec.ts @@ -1,3 +1,5 @@ +import React from 'react'; + import { focusModalChildElement } from './focusModalChildElement'; // Mock the queryFirstFocusableElement function @@ -30,72 +32,135 @@ describe('focusModalChildElement', () => { jest.clearAllMocks(); }); - describe('when a child element has autoFocus', () => { - test('returns the autoFocus element without calling focus()', () => { - const mockAutoFocusElement = { - focus: mockFocus, - } as unknown as HTMLElement; - mockQuerySelector.mockReturnValue(mockAutoFocusElement); + describe('when initialFocus is null', () => { + test('does not focus any element', () => { + const result = focusModalChildElement(mockDialogElement, null); - const result = focusModalChildElement(mockDialogElement); + expect(mockQuerySelector).not.toHaveBeenCalled(); + expect(mockQueryFirstFocusableElement).not.toHaveBeenCalled(); + expect(result).toBeNull(); + }); + }); - expect(mockQuerySelector).toHaveBeenCalledWith('[autofocus]'); - expect(mockFocus).not.toHaveBeenCalled(); // Browser handles autoFocus - expect(result).toBe(mockAutoFocusElement); + describe('when initialFocus is a CSS selector', () => { + test('focuses the element matching the selector', () => { + const mockTargetElement = document.createElement('button'); + mockFocus = jest.fn(); + mockTargetElement.focus = mockFocus; + + mockQuerySelector.mockReturnValue(mockTargetElement); + + const result = focusModalChildElement( + mockDialogElement, + '#submit-button', + ); + + expect(mockQuerySelector).toHaveBeenCalledWith('#submit-button'); + expect(mockFocus).toHaveBeenCalled(); + expect(result).toBe(mockTargetElement); }); + }); - test('returns the autoFocus element', () => { - const mockAutoFocusElement = { - focus: mockFocus, - } as unknown as HTMLElement; - mockQuerySelector.mockReturnValue(mockAutoFocusElement); + describe('when initialFocus is a React ref', () => { + test('focuses the element referenced by the ref', () => { + const mockTargetElement = document.createElement('button'); + mockFocus = jest.fn(); + mockTargetElement.focus = mockFocus; - const result = focusModalChildElement(mockDialogElement); + const mockRef = { + current: mockTargetElement, + } as React.RefObject; - expect(result).toBe(mockAutoFocusElement); + const result = focusModalChildElement(mockDialogElement, mockRef); + + expect(mockFocus).toHaveBeenCalled(); + expect(result).toBe(mockTargetElement); }); }); - describe('when no child element has autoFocus', () => { - test('focuses and returns the first focusable element', () => { + describe('when initialFocus is "auto" or undefined', () => { + test('focuses first focusable element when initialFocus is "auto"', () => { const mockFirstFocusableElement = { focus: mockFocus, } as unknown as HTMLElement; - // No autoFocus element found - mockQuerySelector.mockReturnValue(null); + mockQuerySelector.mockReturnValue(null); // No autoFocus element mockQueryFirstFocusableElement.mockReturnValue(mockFirstFocusableElement); - const result = focusModalChildElement(mockDialogElement); + const result = focusModalChildElement(mockDialogElement, 'auto'); - expect(mockQuerySelector).toHaveBeenCalledWith('[autofocus]'); expect(mockQueryFirstFocusableElement).toHaveBeenCalledWith( mockDialogElement, ); - expect(mockFocus).toHaveBeenCalled(); // We do call focus() for non-autoFocus elements + expect(mockFocus).toHaveBeenCalled(); expect(result).toBe(mockFirstFocusableElement); }); + }); - test('returns the focused element', () => { - const mockFirstFocusableElement = { + describe('when a child element has autoFocus attribute', () => { + test('returns the autoFocus element without calling focus() when initialFocus is "auto"', () => { + const mockAutoFocusElement = { focus: mockFocus, } as unknown as HTMLElement; + mockQuerySelector.mockReturnValue(mockAutoFocusElement); + + const result = focusModalChildElement(mockDialogElement, 'auto'); + + expect(mockQuerySelector).toHaveBeenCalledWith('[autofocus]'); + expect(mockFocus).not.toHaveBeenCalled(); // Browser handles autoFocus + expect(result).toBe(mockAutoFocusElement); + }); + + test('prioritizes initialFocus selector over autoFocus', () => { + const mockTargetElement = document.createElement('button'); + mockFocus = jest.fn(); + mockTargetElement.focus = mockFocus; + + const mockAutoFocusElement = { + focus: jest.fn(), + } as unknown as HTMLElement; - mockQuerySelector.mockReturnValue(null); - mockQueryFirstFocusableElement.mockReturnValue(mockFirstFocusableElement); + mockQuerySelector.mockImplementation((selector: string) => { + if (selector === '#target') return mockTargetElement; + if (selector === '[autofocus]') return mockAutoFocusElement; + return null; + }); - const result = focusModalChildElement(mockDialogElement); + const result = focusModalChildElement(mockDialogElement, '#target'); - expect(result).toBe(mockFirstFocusableElement); + expect(mockFocus).toHaveBeenCalled(); + expect(result).toBe(mockTargetElement); + // autoFocus should not be checked when explicit initialFocus is provided + }); + + test('prioritizes initialFocus ref over autoFocus', () => { + const mockTargetElement = document.createElement('button'); + mockFocus = jest.fn(); + mockTargetElement.focus = mockFocus; + + const mockRef = { + current: mockTargetElement, + } as React.RefObject; + + const mockAutoFocusElement = { + focus: jest.fn(), + } as unknown as HTMLElement; + + mockQuerySelector.mockReturnValue(mockAutoFocusElement); + + const result = focusModalChildElement(mockDialogElement, mockRef); + + expect(mockFocus).toHaveBeenCalled(); + expect(result).toBe(mockTargetElement); }); }); describe('when no focusable elements exist', () => { - test('returns null', () => { + test('returns null when initialFocus is "auto"', () => { mockQuerySelector.mockReturnValue(null); mockQueryFirstFocusableElement.mockReturnValue(null); - const result = focusModalChildElement(mockDialogElement); + const result = focusModalChildElement(mockDialogElement, 'auto'); expect(result).toBeNull(); }); @@ -104,13 +169,73 @@ describe('focusModalChildElement', () => { mockQuerySelector.mockReturnValue(null); mockQueryFirstFocusableElement.mockReturnValue(null); - focusModalChildElement(mockDialogElement); + focusModalChildElement(mockDialogElement, 'auto'); expect(mockFocus).not.toHaveBeenCalled(); }); }); describe('integration with real DOM elements', () => { + test('focuses element by selector', () => { + const container = document.createElement('div'); + container.innerHTML = ` + + + + + `; + document.body.appendChild(container); + + const dialogElement = container.querySelector( + 'dialog', + ) as HTMLDialogElement; + const secondaryButton = container.querySelector( + '#secondary', + ) as HTMLButtonElement; + + const mockFocus = jest.fn(); + secondaryButton.focus = mockFocus; + + const result = focusModalChildElement(dialogElement, '#secondary'); + + expect(mockFocus).toHaveBeenCalled(); + expect(result).toBe(secondaryButton); + + document.body.removeChild(container); + }); + + test('focuses element by ref', () => { + const container = document.createElement('div'); + container.innerHTML = ` + + + + + `; + document.body.appendChild(container); + + const dialogElement = container.querySelector( + 'dialog', + ) as HTMLDialogElement; + const secondaryButton = container.querySelector( + '#secondary', + ) as HTMLButtonElement; + + const mockFocus = jest.fn(); + secondaryButton.focus = mockFocus; + + const buttonRef = { + current: secondaryButton, + } as React.RefObject; + + const result = focusModalChildElement(dialogElement, buttonRef); + + expect(mockFocus).toHaveBeenCalled(); + expect(result).toBe(secondaryButton); + + document.body.removeChild(container); + }); + test('identifies autoFocus element without calling focus()', () => { const container = document.createElement('div'); container.innerHTML = ` @@ -126,16 +251,14 @@ describe('focusModalChildElement', () => { ) as HTMLDialogElement; const inputElement = container.querySelector('input') as HTMLInputElement; - // Mock the focus method to verify it's not called const mockFocus = jest.fn(); inputElement.focus = mockFocus; - const result = focusModalChildElement(dialogElement); + const result = focusModalChildElement(dialogElement, 'auto'); expect(mockFocus).not.toHaveBeenCalled(); // Browser handles autoFocus expect(result).toBe(inputElement); - // Cleanup document.body.removeChild(container); }); @@ -156,19 +279,43 @@ describe('focusModalChildElement', () => { 'button', ) as HTMLButtonElement; - // Mock the focus method to verify it IS called const mockFocus = jest.fn(); buttonElement.focus = mockFocus; - // Mock the queryFirstFocusableElement to return our button mockQueryFirstFocusableElement.mockReturnValue(buttonElement); - const result = focusModalChildElement(dialogElement); + const result = focusModalChildElement(dialogElement, 'auto'); - expect(mockFocus).toHaveBeenCalled(); // We do call focus() for non-autoFocus elements + expect(mockFocus).toHaveBeenCalled(); expect(result).toBe(buttonElement); - // Cleanup + document.body.removeChild(container); + }); + + test('does not focus when initialFocus is null', () => { + const container = document.createElement('div'); + container.innerHTML = ` + + + + `; + document.body.appendChild(container); + + const dialogElement = container.querySelector( + 'dialog', + ) as HTMLDialogElement; + const buttonElement = container.querySelector( + 'button', + ) as HTMLButtonElement; + + const mockFocus = jest.fn(); + buttonElement.focus = mockFocus; + + const result = focusModalChildElement(dialogElement, null); + + expect(mockFocus).not.toHaveBeenCalled(); + expect(result).toBeNull(); + document.body.removeChild(container); }); }); diff --git a/packages/modal/src/utils/focusModalChildElement.ts b/packages/modal/src/utils/focusModalChildElement.ts index 6d11bf38d2..de8aef5563 100644 --- a/packages/modal/src/utils/focusModalChildElement.ts +++ b/packages/modal/src/utils/focusModalChildElement.ts @@ -1,20 +1,46 @@ +import { RefObject } from 'react'; + import { queryFirstFocusableElement } from '@leafygreen-ui/lib'; /** - * Focuses the appropriate element in a modal according to a11y requirements. + * Focuses the appropriate element in a modal according to a11y requirements and user preferences. * - * This function follows the WAI-ARIA Authoring Practices Guide for modals: - * 1. If any child element has `autoFocus`, that element receives focus - * 2. Otherwise, focus the first focusable element within the modal - * 3. The close button serves as a reliable fallback since it's always present + * This function follows the WAI-ARIA Authoring Practices Guide for modals with customization support: + * 1. If `initialFocus` is provided (selector or ref), that element receives focus + * 2. If any child element has `autoFocus`, that element receives focus + * 3. If `initialFocus` is "auto" and no child element has the `autoFocus` attribute, focus the first focusable element within the modal + * 4. If `initialFocus` is `null`, no automatic focus occurs * * @param modalElement - The modal dialog element + * @param initialFocus - Focus target ("auto", selector, ref, or null) * @returns The element that receives focus, or null if no focusable element is focused */ export const focusModalChildElement = ( modalElement: HTMLDialogElement, + initialFocus: 'auto' | string | RefObject | null, ): HTMLElement | null => { - // First, check if any child element has autoFocus + // If explicitly disabled, do nothing. This should rarely be used. + if (initialFocus === null) { + return null; + } + + // Handle explicit initialFocus (string selector or ref) + if (initialFocus !== 'auto') { + let targetElement: HTMLElement | null = null; + + if (typeof initialFocus === 'string') { + targetElement = modalElement.querySelector(initialFocus); + } else if ('current' in initialFocus) { + targetElement = initialFocus.current; + } + + if (targetElement instanceof HTMLElement) { + targetElement.focus(); + return targetElement; + } + } + + // Check if any child element has autoFocus const autoFocusElement = modalElement.querySelector( '[autofocus]', ) as HTMLElement; @@ -24,7 +50,7 @@ export const focusModalChildElement = ( return autoFocusElement; } - // If no autoFocus element, find the first focusable element + // Otherwise, focus first focusable element const firstFocusableElement = queryFirstFocusableElement(modalElement); if (firstFocusableElement) { From e5a98f3d54b5849f791c858221f852fc2fe9de81 Mon Sep 17 00:00:00 2001 From: Stephen Lee Date: Wed, 8 Oct 2025 21:13:32 -0700 Subject: [PATCH 5/8] docs(modal): update docs --- packages/modal/CHANGELOG.md | 9 +++++--- packages/modal/README.md | 43 ++++++++++++++++++++----------------- packages/modal/UPGRADE.md | 42 +++++------------------------------- 3 files changed, 34 insertions(+), 60 deletions(-) diff --git a/packages/modal/CHANGELOG.md b/packages/modal/CHANGELOG.md index 46e8ff3413..8c7a3bd986 100644 --- a/packages/modal/CHANGELOG.md +++ b/packages/modal/CHANGELOG.md @@ -80,15 +80,18 @@ #### Breaking Changes - **Top layer rendering**: Component renders in [top layer](https://developer.mozilla.org/en-US/docs/Glossary/Top_layer) instead of portaling - - **Props**: `className` → `backdropClassName`, `contentClassName` → `className`, `initialFocus` prop removed + - **Props**: `className` → `backdropClassName`, `contentClassName` → `className`, `initialFocus` prop removed (restored in v20.2) - **Backdrop styling**: `backdropClassName` deprecated in favor of CSS `::backdrop` pseudo-element - - **Focus management**: Specifying `autoFocus` on focusable child element replaces manual `initialFocus` prop + - **Focus management**: Modal now automatically focuses the first focusable element by default. Use `autoFocus` attribute on a child element to specify focus target. + - **Note: Automatic focus management introduced in v20 may cause unexpected behavior. Please use v20.2 which has `initialFocus` prop added back.** - **Type changes**: Component now extends `HTMLElementProps<'dialog'>` instead of `HTMLElementProps<'div'>` #### Migration Guide Use the [modal-v20 codemod](https://github.com/mongodb/leafygreen-ui/tree/main/tools/codemods#modal-v20) for migration assistance. + **Note: this has been updated to support the migration to v20.2** + ```shell pnpm lg codemod modal-v20 ``` @@ -97,7 +100,7 @@ 1. Rename `className` prop to `backdropClassName` 2. Rename `contentClassName` prop to `className` - 3. Remove `initialFocus` prop and add guidance comments + 3. Add guidance comment for `initialFocus` prop (prop was removed in v20 but is now available again in v20.2) ## 19.0.1 diff --git a/packages/modal/README.md b/packages/modal/README.md index 3309b37ed3..a778e39ed3 100644 --- a/packages/modal/README.md +++ b/packages/modal/README.md @@ -98,16 +98,17 @@ function ExampleComponent() { ## Properties -| Prop | Type | Description | Default | -| ----------------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | ------------ | -| `children` | `ReactNode` | Content that will appear inside of the Modal component. | | -| `closeIconColor` _(optional)_ | `'default' \| 'dark' \| 'light'` | Determines the color of the close icon. | `'default'` | -| `darkMode` _(optional)_ | `boolean` | Determines if the component will appear in dark mode. | `false` | -| `id` _(optional)_ | `string` | Unique identifier for the Modal. | | -| `open` _(optional)_ | `boolean` | Determines the open state of the modal. | `false` | -| `setOpen` _(optional)_ | `(open: boolean) => void \| React.Dispatch>` | Callback to change the open state of the Modal. | `() => {}` | -| `shouldClose` _(optional)_ | `() => boolean` | Callback to determine whether or not Modal should close when user tries to close it. | `() => true` | -| `size` _(optional)_ | `'small' \| 'default' \| 'large'` | Specifies the size of the Modal. | `'default'` | +| Prop | Type | Description | Default | +| ----------------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | ------------ | +| `children` | `ReactNode` | Content that will appear inside of the Modal component. | | +| `closeIconColor` _(optional)_ | `'default' \| 'dark' \| 'light'` | Determines the color of the close icon. | `'default'` | +| `darkMode` _(optional)_ | `boolean` | Determines if the component will appear in dark mode. | `false` | +| `id` _(optional)_ | `string` | Unique identifier for the Modal. | | +| `initialFocus` _(optional)_ | `'auto' \| string \| React.RefObject \| null` | Specifies which element should receive focus when the modal opens. See [Initial focus behavior](#initial-focus-behavior) for details. | `'auto'` | +| `open` _(optional)_ | `boolean` | Determines the open state of the modal. | `false` | +| `setOpen` _(optional)_ | `(open: boolean) => void \| React.Dispatch>` | Callback to change the open state of the Modal. | `() => {}` | +| `shouldClose` _(optional)_ | `() => boolean` | Callback to determine whether or not Modal should close when user tries to close it. | `() => true` | +| `size` _(optional)_ | `'small' \| 'default' \| 'large'` | Specifies the size of the Modal. | `'default'` | ### v20+ @@ -118,11 +119,10 @@ function ExampleComponent() { ### pre-v20 -| Prop | Type | Description | Default | -| ------------------------------- | -------- | ----------------------------------------------------------------------------------------------------- | ------- | -| `className` _(optional)_ | `string` | Applies a className to the Modal backdrop. | | -| `contentClassName` _(optional)_ | `string` | Applies a className to the Modal content wrapper element | | -| `initialFocus` _(optional)_ | `string` | Selector string (passed to `document.querySelector()) to specify an element to receive initial focus. | | +| Prop | Type | Description | Default | +| ------------------------------- | -------- | -------------------------------------------------------- | ------- | +| `className` _(optional)_ | `string` | Applies a className to the Modal backdrop. | | +| `contentClassName` _(optional)_ | `string` | Applies a className to the Modal content wrapper element | | ## Additional notes @@ -134,13 +134,16 @@ For LeafyGreen UI popover components, this is handled automatically in the lates ### Initial focus behavior -When a Modal opens, it automatically manages focus to ensure proper accessibility. The focus behavior follows this priority order: +When a Modal opens, it automatically manages focus to ensure proper accessibility. You can control this behavior using the `initialFocus` prop. -1. **Explicit focus target**: If any element inside the Modal has the `autoFocus` attribute, that element will receive focus -2. **First focusable element**: If no element has `autoFocus`, focus will be set to the first focusable element (buttons, inputs, links, etc.) found in the Modal content -3. **Close button fallback**: If no focusable elements are found, focus will fall back to the Modal's close button +#### Focus Priority Order -This behavior ensures that users can immediately interact with the Modal content using keyboard navigation without needing to manually specify focus targets. +1. **`initialFocus` prop**: The specified element receives focus + - String selector: `initialFocus="#submit-button"` + - React ref: `initialFocus={submitButtonRef}` +2. **`autoFocus` attribute**: If any child element has the `autoFocus` attribute, that element receives focus +3. **First focusable element**: If `initialFocus` is `"auto"` and no child element has the `autoFocus` attribute, the first focusable element receives focus +4. **No focus**: If `initialFocus` is `null`, no automatic focus occurs ### Using `Clipboard.js` inside `Modal` diff --git a/packages/modal/UPGRADE.md b/packages/modal/UPGRADE.md index 32ffc500c0..35c910983d 100644 --- a/packages/modal/UPGRADE.md +++ b/packages/modal/UPGRADE.md @@ -1,5 +1,7 @@ # Upgrading v19 to v20 +**Note: when upgrading, skip to v20.2. v20 temporarily and prematurely removed the `initialFocus` prop, and it is added back in v20.2** + `Modal` v20 introduces breaking changes that modernize the component with top layer rendering, improved accessibility, and updated prop interfaces. Prior to v20, Modal components used portal-based rendering and different prop names for styling. In v20, the component has been updated to use the native HTML dialog element with top layer rendering for better stacking context management. @@ -9,7 +11,7 @@ Key changes include: - `className` → `backdropClassName` - **Note:** `backdropClassName` is also deprecated in v20 and only provided for migration ease. For custom backdrop styles, use the CSS `::backdrop` pseudo-element to target and style the dialog backdrop instead of relying on this prop. - `contentClassName` → `className` -- `initialFocus` prop removed in favor of `autoFocus` attribute on child elements +- [Initial focus behavior](https://github.com/mongodb/leafygreen-ui/tree/main/packages/modal#initial-focus-behavior) Follow these steps to upgrade: @@ -35,11 +37,7 @@ If you prefer to migrate manually or need to handle edge cases not covered by th **Before:** ```tsx - + ``` @@ -48,9 +46,7 @@ If you prefer to migrate manually or need to handle edge cases not covered by th ```tsx - + ``` @@ -91,31 +87,3 @@ The preferred approach for backdrop styling is now the CSS `::backdrop` pseudo-e } } ``` - -### 3. Update Focus Management - -Replace `initialFocus` with `autoFocus` attribute on the desired element: - -**Before:** - -```tsx - -
- - -
-
-``` - -**After:** - -```tsx - -
- - -
-
-``` - -If no element has `autoFocus`, the first focusable element will automatically receive focus. From 63ba35d8275f985d6bbb08b66f60a567ff89a94a Mon Sep 17 00:00:00 2001 From: Stephen Lee Date: Wed, 8 Oct 2025 21:13:38 -0700 Subject: [PATCH 6/8] chore(modal): changeset --- .changeset/modal-initial-focus-revert.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/modal-initial-focus-revert.md diff --git a/.changeset/modal-initial-focus-revert.md b/.changeset/modal-initial-focus-revert.md new file mode 100644 index 0000000000..ae1a2b8372 --- /dev/null +++ b/.changeset/modal-initial-focus-revert.md @@ -0,0 +1,7 @@ +--- +'@leafygreen-ui/modal': minor +--- + +[LG-5608](https://jira.mongodb.org/browse/LG-5608) + +In v20, the `initialFocus` prop was prematurely removed without proper migration paths. This change restores the prop with enhanced functionality and control. See more on [initial focus behavior](https://github.com/mongodb/leafygreen-ui/tree/main/packages/modal#initial-focus-behavior). From 94c9a12bdc7965c873e722b9cb898c55c4a31927 Mon Sep 17 00:00:00 2001 From: Stephen Lee Date: Thu, 9 Oct 2025 06:51:51 -0700 Subject: [PATCH 7/8] fix(modal): use try-finally blocks in focusModalChildElement tests --- .../src/utils/focusModalChildElement.spec.ts | 155 ++++++++++-------- 1 file changed, 85 insertions(+), 70 deletions(-) diff --git a/packages/modal/src/utils/focusModalChildElement.spec.ts b/packages/modal/src/utils/focusModalChildElement.spec.ts index a59361f6af..510efa9c49 100644 --- a/packages/modal/src/utils/focusModalChildElement.spec.ts +++ b/packages/modal/src/utils/focusModalChildElement.spec.ts @@ -22,14 +22,12 @@ describe('focusModalChildElement', () => { beforeEach(() => { mockFocus = jest.fn(); mockQuerySelector = jest.fn(); + mockQueryFirstFocusableElement.mockClear(); // Create a mock dialog element mockDialogElement = { querySelector: mockQuerySelector, } as unknown as HTMLDialogElement; - - // Reset mocks - jest.clearAllMocks(); }); describe('when initialFocus is null', () => { @@ -184,24 +182,27 @@ describe('focusModalChildElement', () => { `; - document.body.appendChild(container); - const dialogElement = container.querySelector( - 'dialog', - ) as HTMLDialogElement; - const secondaryButton = container.querySelector( - '#secondary', - ) as HTMLButtonElement; + try { + document.body.appendChild(container); - const mockFocus = jest.fn(); - secondaryButton.focus = mockFocus; + const dialogElement = container.querySelector( + 'dialog', + ) as HTMLDialogElement; + const secondaryButton = container.querySelector( + '#secondary', + ) as HTMLButtonElement; - const result = focusModalChildElement(dialogElement, '#secondary'); + const mockFocus = jest.fn(); + secondaryButton.focus = mockFocus; - expect(mockFocus).toHaveBeenCalled(); - expect(result).toBe(secondaryButton); + const result = focusModalChildElement(dialogElement, '#secondary'); - document.body.removeChild(container); + expect(mockFocus).toHaveBeenCalled(); + expect(result).toBe(secondaryButton); + } finally { + document.body.removeChild(container); + } }); test('focuses element by ref', () => { @@ -212,28 +213,31 @@ describe('focusModalChildElement', () => { `; - document.body.appendChild(container); - const dialogElement = container.querySelector( - 'dialog', - ) as HTMLDialogElement; - const secondaryButton = container.querySelector( - '#secondary', - ) as HTMLButtonElement; + try { + document.body.appendChild(container); - const mockFocus = jest.fn(); - secondaryButton.focus = mockFocus; + const dialogElement = container.querySelector( + 'dialog', + ) as HTMLDialogElement; + const secondaryButton = container.querySelector( + '#secondary', + ) as HTMLButtonElement; - const buttonRef = { - current: secondaryButton, - } as React.RefObject; + const mockFocus = jest.fn(); + secondaryButton.focus = mockFocus; - const result = focusModalChildElement(dialogElement, buttonRef); + const buttonRef = { + current: secondaryButton, + } as React.RefObject; - expect(mockFocus).toHaveBeenCalled(); - expect(result).toBe(secondaryButton); + const result = focusModalChildElement(dialogElement, buttonRef); - document.body.removeChild(container); + expect(mockFocus).toHaveBeenCalled(); + expect(result).toBe(secondaryButton); + } finally { + document.body.removeChild(container); + } }); test('identifies autoFocus element without calling focus()', () => { @@ -244,22 +248,27 @@ describe('focusModalChildElement', () => { `; - document.body.appendChild(container); - const dialogElement = container.querySelector( - 'dialog', - ) as HTMLDialogElement; - const inputElement = container.querySelector('input') as HTMLInputElement; + try { + document.body.appendChild(container); - const mockFocus = jest.fn(); - inputElement.focus = mockFocus; + const dialogElement = container.querySelector( + 'dialog', + ) as HTMLDialogElement; + const inputElement = container.querySelector( + 'input', + ) as HTMLInputElement; - const result = focusModalChildElement(dialogElement, 'auto'); + const mockFocus = jest.fn(); + inputElement.focus = mockFocus; - expect(mockFocus).not.toHaveBeenCalled(); // Browser handles autoFocus - expect(result).toBe(inputElement); + const result = focusModalChildElement(dialogElement, 'auto'); - document.body.removeChild(container); + expect(mockFocus).not.toHaveBeenCalled(); // Browser handles autoFocus + expect(result).toBe(inputElement); + } finally { + document.body.removeChild(container); + } }); test('focuses first focusable element when no autoFocus exists', () => { @@ -270,26 +279,29 @@ describe('focusModalChildElement', () => { `; - document.body.appendChild(container); - const dialogElement = container.querySelector( - 'dialog', - ) as HTMLDialogElement; - const buttonElement = container.querySelector( - 'button', - ) as HTMLButtonElement; + try { + document.body.appendChild(container); - const mockFocus = jest.fn(); - buttonElement.focus = mockFocus; + const dialogElement = container.querySelector( + 'dialog', + ) as HTMLDialogElement; + const buttonElement = container.querySelector( + 'button', + ) as HTMLButtonElement; - mockQueryFirstFocusableElement.mockReturnValue(buttonElement); + const mockFocus = jest.fn(); + buttonElement.focus = mockFocus; - const result = focusModalChildElement(dialogElement, 'auto'); + mockQueryFirstFocusableElement.mockReturnValue(buttonElement); - expect(mockFocus).toHaveBeenCalled(); - expect(result).toBe(buttonElement); + const result = focusModalChildElement(dialogElement, 'auto'); - document.body.removeChild(container); + expect(mockFocus).toHaveBeenCalled(); + expect(result).toBe(buttonElement); + } finally { + document.body.removeChild(container); + } }); test('does not focus when initialFocus is null', () => { @@ -299,24 +311,27 @@ describe('focusModalChildElement', () => { `; - document.body.appendChild(container); - const dialogElement = container.querySelector( - 'dialog', - ) as HTMLDialogElement; - const buttonElement = container.querySelector( - 'button', - ) as HTMLButtonElement; + try { + document.body.appendChild(container); - const mockFocus = jest.fn(); - buttonElement.focus = mockFocus; + const dialogElement = container.querySelector( + 'dialog', + ) as HTMLDialogElement; + const buttonElement = container.querySelector( + 'button', + ) as HTMLButtonElement; - const result = focusModalChildElement(dialogElement, null); + const mockFocus = jest.fn(); + buttonElement.focus = mockFocus; - expect(mockFocus).not.toHaveBeenCalled(); - expect(result).toBeNull(); + const result = focusModalChildElement(dialogElement, null); - document.body.removeChild(container); + expect(mockFocus).not.toHaveBeenCalled(); + expect(result).toBeNull(); + } finally { + document.body.removeChild(container); + } }); }); }); From f6058dffbca9c2a5d64118351b2101bee4c0f632 Mon Sep 17 00:00:00 2001 From: Stephen Lee Date: Thu, 9 Oct 2025 06:56:31 -0700 Subject: [PATCH 8/8] docs(modal): recommend ref over css selector --- packages/modal/README.md | 2 +- packages/modal/src/Modal/Modal.types.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/modal/README.md b/packages/modal/README.md index a778e39ed3..4c9ed6a099 100644 --- a/packages/modal/README.md +++ b/packages/modal/README.md @@ -140,7 +140,7 @@ When a Modal opens, it automatically manages focus to ensure proper accessibilit 1. **`initialFocus` prop**: The specified element receives focus - String selector: `initialFocus="#submit-button"` - - React ref: `initialFocus={submitButtonRef}` + - React ref: `initialFocus={submitButtonRef}` (recommended over string selector for better type safety) 2. **`autoFocus` attribute**: If any child element has the `autoFocus` attribute, that element receives focus 3. **First focusable element**: If `initialFocus` is `"auto"` and no child element has the `autoFocus` attribute, the first focusable element receives focus 4. **No focus**: If `initialFocus` is `null`, no automatic focus occurs diff --git a/packages/modal/src/Modal/Modal.types.ts b/packages/modal/src/Modal/Modal.types.ts index 589eb17255..2151c4f268 100644 --- a/packages/modal/src/Modal/Modal.types.ts +++ b/packages/modal/src/Modal/Modal.types.ts @@ -52,7 +52,7 @@ export interface ModalProps * **Options:** * - `"auto"`: Automatically focuses the first focusable element in the modal * - `string`: CSS selector passed to `querySelector()` to specify an element - * - `React.RefObject`: Reference to the element that should receive focus + * - `React.RefObject`: Reference to the element that should receive focus. This is recommended over using a CSS selector for better type safety. * - `null`: Disables automatic focus management. Use sparingly - disabling focus management may create accessibility issues * * **Priority order:** @@ -70,7 +70,7 @@ export interface ModalProps *
* * @example - * // Using a ref + * // Using a ref (recommended over selector) * const submitRef = useRef(null); * *