Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/codemods-modal-v20-update.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions .changeset/modal-initial-focus-revert.md
Original file line number Diff line number Diff line change
@@ -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).
9 changes: 6 additions & 3 deletions packages/modal/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path>
```
Expand All @@ -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

Expand Down
43 changes: 23 additions & 20 deletions packages/modal/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<SetStateAction<boolean>>` | 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<HTMLElement> \| 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<SetStateAction<boolean>>` | 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+

Expand All @@ -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

Expand All @@ -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}` (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

### Using `Clipboard.js` inside `Modal`

Expand Down
42 changes: 5 additions & 37 deletions packages/modal/UPGRADE.md
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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:

Expand All @@ -35,11 +37,7 @@ If you prefer to migrate manually or need to handle edge cases not covered by th
**Before:**

```tsx
<Modal
className="modal-backdrop-styles"
contentClassName="modal-root-styles"
initialFocus="#primary-button"
>
<Modal className="modal-backdrop-styles" contentClassName="modal-root-styles">
<button id="primary-button">Action</button>
</Modal>
```
Expand All @@ -48,9 +46,7 @@ If you prefer to migrate manually or need to handle edge cases not covered by th

```tsx
<Modal backdropClassName="modal-backdrop-styles" className="modal-root-styles">
<button id="primary-button" autoFocus>
Action
</button>
<button id="primary-button">Action</button>
</Modal>
```

Expand Down Expand Up @@ -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
<Modal initialFocus="#submit-btn">
<form>
<input type="text" />
<button id="submit-btn">Submit</button>
</form>
</Modal>
```

**After:**

```tsx
<Modal>
<form>
<input type="text" />
<button autoFocus>Submit</button>
</form>
</Modal>
```

If no element has `autoFocus`, the first focusable element will automatically receive focus.
131 changes: 111 additions & 20 deletions packages/modal/src/Modal/Modal.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -188,29 +189,119 @@ describe('packages/modal', () => {
expect(modal).toHaveAttribute('open');
});

test('supports autofocus on child elements', () => {
const { getByTestId } = render(
<ModalView open={true}>
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
<input data-testid="auto-focus-input" autoFocus />
<button>Submit</button>
</ModalView>,
);
describe('initialFocus prop', () => {
test('focuses element specified by string selector', () => {
const { getByTestId } = render(
<Modal open={true} initialFocus="#secondary-button">
<button data-testid="primary-button" id="primary-button">
Primary
</button>
<button data-testid="secondary-button" id="secondary-button">
Secondary
</button>
</Modal>,
);

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<HTMLButtonElement>(null);

test('focuses first focusable element when no autoFocus is set', () => {
const { getByRole } = render(
<ModalView open={true}>
<button>Submit</button>
<input />
</ModalView>,
);
return (
<Modal open={true} initialFocus={buttonRef}>
<button data-testid="primary-button">Primary</button>
<button data-testid="secondary-button" ref={buttonRef}>
Secondary
</button>
</Modal>
);
};

const { getByTestId } = render(<TestComponent />);

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(
<Modal open={true} initialFocus={null}>
<button data-testid="button">Submit</button>
<input data-testid="input" />
</Modal>,
);

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(
<Modal open={true} initialFocus="auto">
<button data-testid="first-button">First</button>
<button data-testid="second-button">Second</button>
</Modal>,
);

const firstButton = getByTestId('first-button');
expect(firstButton).toHaveFocus();
});

test('initialFocus selector takes priority over autoFocus attribute', () => {
const { getByTestId } = render(
<Modal open={true} initialFocus="#target-button">
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
<input data-testid="auto-input" autoFocus />
<button data-testid="target-button" id="target-button">
Target
</button>
</Modal>,
);

const targetButton = getByTestId('target-button');
expect(targetButton).toHaveFocus();
});

test('initialFocus ref takes priority over autoFocus attribute', () => {
const TestComponent = () => {
const buttonRef = React.useRef<HTMLButtonElement>(null);

return (
<Modal open={true} initialFocus={buttonRef}>
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
<input data-testid="auto-input" autoFocus />
<button data-testid="target-button" ref={buttonRef}>
Target
</button>
</Modal>
);
};

const { getByTestId } = render(<TestComponent />);

const targetButton = getByTestId('target-button');
expect(targetButton).toHaveFocus();
});

test('falls back to autoFocus when initialFocus selector is not found', () => {
const { getByTestId } = render(
<Modal open={true} initialFocus="#nonexistent">
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
<input data-testid="auto-input" autoFocus />
<button data-testid="button">Submit</button>
</Modal>,
);

const input = getByTestId('auto-input');
expect(input).toHaveFocus();
});
});

test('deprecated backdropClassName still works', () => {
Expand Down
44 changes: 44 additions & 0 deletions packages/modal/src/Modal/Modal.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLElement>`: 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:**
* 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
* <Modal>
* <button autoFocus>Submit</button>
* </Modal>
*
* @example
* // Using a ref (recommended over selector)
* const submitRef = useRef<HTMLButtonElement>(null);
* <Modal initialFocus={submitRef}>
* <button ref={submitRef}>Submit</button>
* </Modal>
*
* @example
* // Using a selector
* <Modal initialFocus="#submit-button">
* <button id="submit-button">Submit</button>
* </Modal>
*
* @example
* // Disabling automatic focus
* <Modal initialFocus={null}>
* <CustomEditor />
* </Modal>
*/
initialFocus?: 'auto' | string | React.RefObject<HTMLElement> | null;

/**
* @deprecated Use CSS `::backdrop` pseudo-element instead. This prop will be removed in a future version.
*
Expand Down
Loading
Loading