Skip to content

Commit 5994b73

Browse files
authored
[LG-4952] feat(modal): Modal renders as dialog element in top layer (#3048)
* chore(modal): add @leafygreen-ui/toast as dev dep * feat(modal): add focusModalChildElement util * [breaking] refactor(modal): use <dialog> element in LG modals, remove initialFocus prop, and update custom styling props * docs(modals): update READMEs * chore(modals): changeset * chore(modal): remove unused deps * [LG-4952] feat(codemods): add modal-v20 codemod for automatic Modal v20 upgrade (#3049) * feat(codemods): modal-v20 🚀 * docs: update codemods README with modal-v20 details and add modal package upgrade guide * chore: modal and codemods changesets * refactor: feedback
1 parent d810753 commit 5994b73

35 files changed

+1861
-420
lines changed

.changeset/famous-dancers-wink.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@lg-tools/codemods': minor
3+
---
4+
5+
[LG-4952](https://jira.mongodb.org/browse/LG-4952): Add `modal-v20` codemod which can be used when upgrading to `@leafygreen-ui/modal` v20+ and other LG modals. More details in [codemods README](https://github.com/mongodb/leafygreen-ui/tree/main/tools/codemods#modal-v20)

.changeset/plain-banks-take.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
'@leafygreen-ui/confirmation-modal': major
3+
'@leafygreen-ui/marketing-modal': major
4+
'@leafygreen-ui/modal': major
5+
---
6+
7+
[LG-4952](https://jira.mongodb.org/browse/LG-4952)
8+
9+
#### Breaking Changes
10+
11+
- **Top layer rendering**: Component renders in [top layer](https://developer.mozilla.org/en-US/docs/Glossary/Top_layer) instead of portaling
12+
- **Props**: `className``backdropClassName`, `contentClassName``className`, `initialFocus` prop removed
13+
- **Backdrop styling**: `backdropClassName` deprecated in favor of CSS `::backdrop` pseudo-element
14+
- **Focus management**: Specifying `autoFocus` on focusable child element replaces manual `initialFocus` prop
15+
- **Type changes**: Component now extends `HTMLElementProps<'dialog'>` instead of `HTMLElementProps<'div'>`
16+
17+
#### Migration Guide
18+
19+
Use the [modal-v20 codemod]([popover-v12 codemod](https://github.com/mongodb/leafygreen-ui/tree/main/tools/codemods#modal-v20) for migration assistance.
20+
21+
```shell
22+
pnpm lg codemod modal-v20 <path>
23+
```
24+
25+
The codemod will:
26+
1. Rename `className` prop to `backdropClassName`
27+
2. Rename `contentClassName` prop to `className`
28+
3. Remove `initialFocus` prop and add guidance comments

packages/confirmation-modal/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ npm install @leafygreen-ui/confirmation-modal
2626

2727
## Example
2828

29-
```js
29+
```tsx
3030
import ConfirmationModal from '@leafygreen-ui/confirmation-modal';
3131

3232
function ExampleComponent() {

packages/confirmation-modal/src/ConfirmationModal/ConfirmationModal.spec.tsx

Lines changed: 55 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,5 @@
11
import React, { useState } from 'react';
2-
import {
3-
act,
4-
fireEvent,
5-
render,
6-
waitForElementToBeRemoved,
7-
} from '@testing-library/react';
2+
import { act, render, waitFor } from '@testing-library/react';
83
import userEvent from '@testing-library/user-event';
94
import { axe } from 'jest-axe';
105

@@ -42,14 +37,35 @@ function renderModal(
4237
}
4338

4439
describe('packages/confirmation-modal', () => {
40+
// Mock dialog methods for JSDOM environment
41+
beforeAll(() => {
42+
HTMLDialogElement.prototype.show = jest.fn(function mock(
43+
this: HTMLDialogElement,
44+
) {
45+
this.open = true;
46+
});
47+
48+
HTMLDialogElement.prototype.showModal = jest.fn(function mock(
49+
this: HTMLDialogElement,
50+
) {
51+
this.open = true;
52+
});
53+
54+
HTMLDialogElement.prototype.close = jest.fn(function mock(
55+
this: HTMLDialogElement,
56+
) {
57+
this.open = false;
58+
});
59+
});
60+
4561
describe('a11y', () => {
4662
test('does not have basic accessibility issues', async () => {
4763
const { container, getByText } = renderModal({ open: true });
4864
const results = await axe(container);
4965
expect(results).toHaveNoViolations();
5066

5167
let newResults = null as any;
52-
act(() => void fireEvent.click(getByText('Confirm')));
68+
act(() => void userEvent.click(getByText('Confirm')));
5369
await act(async () => {
5470
newResults = await axe(container);
5571
});
@@ -58,8 +74,8 @@ describe('packages/confirmation-modal', () => {
5874
});
5975

6076
test('does not render if closed', () => {
61-
renderModal();
62-
expect(document.body.innerHTML).toEqual('<div></div>');
77+
const { getByText } = renderModal();
78+
expect(getByText('Content text')).not.toBeVisible();
6379
});
6480

6581
test('renders if open', () => {
@@ -122,7 +138,7 @@ describe('packages/confirmation-modal', () => {
122138
const button = getByText('Confirm');
123139
expect(button).toBeVisible();
124140

125-
fireEvent.click(button);
141+
userEvent.click(button);
126142
expect(confirmSpy).toHaveBeenCalledTimes(1);
127143
expect(cancelSpy).not.toHaveBeenCalled();
128144
});
@@ -141,7 +157,7 @@ describe('packages/confirmation-modal', () => {
141157
const button = getByText('Confirm');
142158
expect(button).toBeVisible();
143159

144-
fireEvent.click(button);
160+
userEvent.click(button);
145161
expect(confirmSpy).toHaveBeenCalledTimes(1);
146162
});
147163
});
@@ -161,7 +177,7 @@ describe('packages/confirmation-modal', () => {
161177
const button = getByText('Cancel');
162178
expect(button).toBeVisible();
163179

164-
fireEvent.click(button);
180+
userEvent.click(button);
165181
expect(confirmSpy).not.toHaveBeenCalled();
166182
expect(cancelSpy).toHaveBeenCalledTimes(1);
167183
});
@@ -181,7 +197,7 @@ describe('packages/confirmation-modal', () => {
181197
const button = getByText('Cancel');
182198
expect(button).toBeVisible();
183199

184-
fireEvent.click(button);
200+
userEvent.click(button);
185201
expect(confirmSpy).not.toHaveBeenCalled();
186202
expect(cancelSpy).toHaveBeenCalledTimes(1);
187203
});
@@ -192,19 +208,19 @@ describe('packages/confirmation-modal', () => {
192208
const { getByRole } = renderModal({ open: true });
193209
const modal = getByRole('dialog');
194210

195-
fireEvent.keyDown(document, { key: 'Escape', keyCode: 27 });
211+
userEvent.keyboard('{Escape}');
196212

197-
await waitForElementToBeRemoved(modal);
213+
await waitFor(() => expect(modal).not.toBeVisible());
198214
});
199215

200216
test('x icon is clicked', async () => {
201217
const { getByLabelText, getByRole } = renderModal({ open: true });
202218
const modal = getByRole('dialog');
203219

204220
const x = getByLabelText('Close modal');
205-
fireEvent.click(x);
221+
userEvent.click(x);
206222

207-
await waitForElementToBeRemoved(modal);
223+
await waitFor(() => expect(modal).not.toBeVisible());
208224
});
209225
});
210226

@@ -227,18 +243,22 @@ describe('packages/confirmation-modal', () => {
227243
expect(textInput).toBe(document.activeElement);
228244

229245
// Should still be disabled after partial entry
230-
fireEvent.change(textInput, { target: { value: 'Confir' } });
246+
userEvent.clear(textInput);
247+
userEvent.type(textInput, 'Confir');
231248
expect(confirmationButton).toHaveAttribute('aria-disabled', 'true');
232249

233-
fireEvent.change(textInput, { target: { value: 'Confirm' } });
250+
userEvent.clear(textInput);
251+
userEvent.type(textInput, 'Confirm');
234252
expect(confirmationButton).not.toHaveAttribute('aria-disabled', 'true');
235253

236254
// Should be disabled again
237-
fireEvent.change(textInput, { target: { value: 'Confirm?' } });
255+
userEvent.clear(textInput);
256+
userEvent.type(textInput, 'Confirm?');
238257
expect(confirmationButton).toHaveAttribute('aria-disabled', 'true');
239258

240259
// Case matters
241-
fireEvent.change(textInput, { target: { value: 'confirm' } });
260+
userEvent.clear(textInput);
261+
userEvent.type(textInput, 'confirm');
242262
expect(confirmationButton).toHaveAttribute('aria-disabled', 'true');
243263
});
244264

@@ -287,9 +307,8 @@ describe('packages/confirmation-modal', () => {
287307
textInput = getByLabelText(
288308
`Type "${requiredInputText}" to confirm your action`,
289309
);
290-
fireEvent.change(textInput, {
291-
target: { value: requiredInputText },
292-
});
310+
userEvent.clear(textInput);
311+
userEvent.type(textInput, requiredInputText);
293312
expect(confirmationButton).not.toHaveAttribute(
294313
'aria-disabled',
295314
'true',
@@ -298,7 +317,7 @@ describe('packages/confirmation-modal', () => {
298317

299318
userEvent.click(buttonToClick);
300319

301-
await waitForElementToBeRemoved(modal);
320+
await waitFor(() => expect(modal).not.toBeVisible());
302321

303322
rerender(
304323
<ConfirmationModal
@@ -354,8 +373,8 @@ describe('packages/confirmation-modal', () => {
354373
expect(button).toBeVisible();
355374

356375
// Modal doesn't close when button is clicked
357-
fireEvent.click(button);
358-
await waitForElementToBeRemoved(modal);
376+
userEvent.click(button);
377+
await waitFor(() => expect(modal).not.toBeVisible());
359378
});
360379

361380
test('"confirmButtonProps" has "disabled: false"', async () => {
@@ -374,8 +393,8 @@ describe('packages/confirmation-modal', () => {
374393
expect(button).toBeVisible();
375394

376395
// Modal doesn't close when button is clicked
377-
fireEvent.click(button);
378-
await waitForElementToBeRemoved(modal);
396+
userEvent.click(button);
397+
await waitFor(() => expect(modal).not.toBeVisible());
379398
});
380399
});
381400

@@ -395,7 +414,7 @@ describe('packages/confirmation-modal', () => {
395414
expect(button).toBeVisible();
396415

397416
// Modal doesn't close when button is clicked
398-
fireEvent.click(button);
417+
userEvent.click(button);
399418
expect(button).toBeVisible();
400419
});
401420

@@ -413,7 +432,7 @@ describe('packages/confirmation-modal', () => {
413432
expect(button).toBeVisible();
414433

415434
// Modal doesn't close when button is clicked
416-
fireEvent.click(button);
435+
userEvent.click(button);
417436
expect(button).toBeVisible();
418437
});
419438

@@ -434,7 +453,7 @@ describe('packages/confirmation-modal', () => {
434453
expect(button).toBeVisible();
435454

436455
// Modal doesn't close when button is clicked
437-
fireEvent.click(button);
456+
userEvent.click(button);
438457
expect(button).toBeVisible();
439458
});
440459

@@ -453,7 +472,8 @@ describe('packages/confirmation-modal', () => {
453472
const textInput = getByLabelText('Type "Confirm" to confirm your action');
454473
expect(textInput).toBeVisible();
455474

456-
fireEvent.change(textInput, { target: { value: 'Confirm' } });
475+
userEvent.clear(textInput);
476+
userEvent.type(textInput, 'Confirm');
457477
expect(confirmationButton).toHaveAttribute('aria-disabled', 'true');
458478
});
459479

@@ -471,7 +491,8 @@ describe('packages/confirmation-modal', () => {
471491
const textInput = getByLabelText('Type "Confirm" to confirm your action');
472492
expect(textInput).toBeVisible();
473493

474-
fireEvent.change(textInput, { target: { value: 'Confirm' } });
494+
userEvent.clear(textInput);
495+
userEvent.type(textInput, 'Confirm');
475496
expect(confirmationButton).toHaveAttribute('aria-disabled', 'true');
476497
});
477498
});

packages/confirmation-modal/src/ConfirmationModal/ConfirmationModal.tsx

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useMemo, useState } from 'react';
1+
import React, { forwardRef, useMemo, useState } from 'react';
22

33
import Button from '@leafygreen-ui/button';
44
import { cx } from '@leafygreen-ui/emotion';
@@ -27,7 +27,10 @@ import {
2727
/**
2828
* Modals can be used to display a simple task, confirm actions, prompt users to input information, or display additional information.
2929
*/
30-
export const ConfirmationModal = React.forwardRef(
30+
export const ConfirmationModal = forwardRef<
31+
HTMLDialogElement,
32+
ConfirmationModalProps
33+
>(
3134
(
3235
{
3336
children,
@@ -43,8 +46,8 @@ export const ConfirmationModal = React.forwardRef(
4346
cancelButtonProps = {},
4447
'data-lgid': dataLgId,
4548
...modalProps
46-
}: ConfirmationModalProps,
47-
forwardRef: React.ForwardedRef<HTMLDivElement | null>,
49+
},
50+
fwdRef,
4851
) => {
4952
const [confirmEnabled, setConfirmEnabled] = useState(!requiredInputText);
5053
const { theme, darkMode } = useDarkMode(darkModeProp);
@@ -105,10 +108,10 @@ export const ConfirmationModal = React.forwardRef(
105108
data-testid={lgIds.root}
106109
data-lgid={lgIds.root}
107110
{...modalProps}
108-
contentClassName={baseModalStyle}
111+
className={baseModalStyle}
109112
setOpen={handleCancel}
110113
darkMode={darkMode}
111-
ref={forwardRef}
114+
ref={fwdRef}
112115
>
113116
<div
114117
className={cx(contentStyle, contentVariantStyles[variant], {

packages/marketing-modal/README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@ npm install @leafygreen-ui/marketing-modal
2626

2727
## Example
2828

29-
```js
29+
```tsx
30+
import MarketingModal from '@leafygreen-ui/marketing-modal';
31+
3032
function Example() {
3133
const [open, setOpen] = useState(false);
3234

packages/marketing-modal/src/MarketingModal/MarketingModal.spec.tsx

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React, { useState } from 'react';
2-
import { act, render, waitForElementToBeRemoved } from '@testing-library/react';
2+
import { act, render, waitFor } from '@testing-library/react';
33
import userEvent from '@testing-library/user-event';
44
import { axe } from 'jest-axe';
55

@@ -41,6 +41,27 @@ function renderModal(
4141
}
4242

4343
describe('packages/marketing-modal', () => {
44+
// Mock dialog methods for JSDOM environment
45+
beforeAll(() => {
46+
HTMLDialogElement.prototype.show = jest.fn(function mock(
47+
this: HTMLDialogElement,
48+
) {
49+
this.open = true;
50+
});
51+
52+
HTMLDialogElement.prototype.showModal = jest.fn(function mock(
53+
this: HTMLDialogElement,
54+
) {
55+
this.open = true;
56+
});
57+
58+
HTMLDialogElement.prototype.close = jest.fn(function mock(
59+
this: HTMLDialogElement,
60+
) {
61+
this.open = false;
62+
});
63+
});
64+
4465
describe('a11y', () => {
4566
test('does not have basic accessibility issues', async () => {
4667
const { container, getByText } = renderModal({ open: true });
@@ -56,8 +77,8 @@ describe('packages/marketing-modal', () => {
5677
});
5778
});
5879
test('does not render if closed', () => {
59-
renderModal();
60-
expect(document.body.innerHTML).toEqual('<div></div>');
80+
const { getByText } = renderModal();
81+
expect(getByText('Content text')).not.toBeVisible();
6182
});
6283

6384
test('renders if open', () => {
@@ -125,7 +146,7 @@ describe('packages/marketing-modal', () => {
125146

126147
userEvent.keyboard('{Escape}');
127148

128-
await waitForElementToBeRemoved(modal);
149+
await waitFor(() => expect(modal).not.toBeVisible());
129150
});
130151

131152
test('x icon is clicked', async () => {
@@ -135,7 +156,7 @@ describe('packages/marketing-modal', () => {
135156
const x = getByLabelText('Close modal');
136157
userEvent.click(x);
137158

138-
await waitForElementToBeRemoved(modal);
159+
await waitFor(() => expect(modal).not.toBeVisible());
139160
});
140161
});
141162

0 commit comments

Comments
 (0)