Skip to content
Open
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
21 changes: 21 additions & 0 deletions packages/@adobe/react-spectrum/src/dialog/AlertDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,26 @@ export const AlertDialog = forwardRef(function AlertDialog(
}
}

// The Escape key is normally handled by the overlay, which closes the dialog without
// pressing the cancel button. Handle it on the dialog itself instead, so that dismissing
// an AlertDialog with the Escape key is equivalent to pressing the cancel button.
let onKeyDown = (e: React.KeyboardEvent) => {
if (
e.key === 'Escape' &&
!e.nativeEvent.isComposing &&
!e.nativeEvent.repeat &&
!e.altKey &&
!e.ctrlKey &&
!e.shiftKey &&
!e.metaKey
) {
onClose();
onCancel();
e.stopPropagation();
e.preventDefault();
}
};

return (
<Dialog
UNSAFE_style={styleProps.style}
Expand All @@ -104,6 +124,7 @@ export const AlertDialog = forwardRef(function AlertDialog(
isHidden={styleProps.hidden}
size="M"
role="alertdialog"
onKeyDown={onKeyDown}
ref={ref}
{...filterDOMProps(props, {labelable: true})}>
<Heading>{title}</Heading>
Expand Down
139 changes: 138 additions & 1 deletion packages/@adobe/react-spectrum/test/dialog/AlertDialog.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@
* governing permissions and limitations under the License.
*/

import {act, pointerMap, render, simulateDesktop} from '@react-spectrum/test-utils-internal';
import {ActionButton} from '../../src/button/ActionButton';
import {AlertDialog} from '../../src/dialog/AlertDialog';
import {pointerMap, render} from '@react-spectrum/test-utils-internal';
import {DialogTrigger} from '../../src/dialog/DialogTrigger';
import {Provider} from '../../src/provider/Provider';
import React from 'react';
import {defaultTheme as theme} from '../../src/theme-default/defaultTheme';
Expand Down Expand Up @@ -279,4 +281,139 @@ describe('AlertDialog', function () {

expect(getByRole('alertdialog')).toHaveAttribute('aria-describedby', 'content-id');
});

it('fires onCancel when the Escape key is pressed', async function () {
let user = userEvent.setup({delay: null, pointerMap});
let onCancelSpy = jest.fn();
let {getByRole} = render(
<Provider theme={theme}>
<AlertDialog
variant="confirmation"
title="the title"
primaryActionLabel="confirm"
cancelLabel="cancel"
onCancel={onCancelSpy}>
Content body
</AlertDialog>
</Provider>
);

let dialog = getByRole('alertdialog');
expect(document.activeElement).toBe(dialog);

await user.keyboard('{Escape}');
expect(onCancelSpy).toHaveBeenCalledTimes(1);
expect(onCancelSpy).toHaveBeenCalledWith();
});

it('does not fire other handlers on Escape when onCancel is not provided', async function () {
let user = userEvent.setup({delay: null, pointerMap});
let onPrimaryAction = jest.fn();
let {getByRole} = render(
<Provider theme={theme}>
<AlertDialog
variant="confirmation"
title="the title"
primaryActionLabel="confirm"
onPrimaryAction={onPrimaryAction}>
Content body
</AlertDialog>
</Provider>
);

let dialog = getByRole('alertdialog');
expect(document.activeElement).toBe(dialog);

await user.keyboard('{Escape}');
expect(onPrimaryAction).toHaveBeenCalledTimes(0);
});

describe('within a DialogTrigger', function () {
beforeAll(() => {
jest.useFakeTimers();
});

beforeEach(() => {
simulateDesktop();
});

afterEach(() => {
act(() => jest.runAllTimers());
});

afterAll(() => {
jest.restoreAllMocks();
jest.useRealTimers();
});

it('fires onCancel once and closes the dialog when the Escape key is pressed', async function () {
let user = userEvent.setup({delay: null, pointerMap});
let onCancelSpy = jest.fn();
let {getByRole} = render(
<Provider theme={theme}>
<DialogTrigger>
<ActionButton>Trigger</ActionButton>
<AlertDialog
variant="confirmation"
title="the title"
primaryActionLabel="confirm"
cancelLabel="cancel"
onCancel={onCancelSpy}>
Content body
</AlertDialog>
</DialogTrigger>
</Provider>
);

let button = getByRole('button');
await user.click(button);
act(() => {
jest.runAllTimers();
});

let dialog = getByRole('alertdialog');

await user.keyboard('{Escape}');
act(() => {
jest.runAllTimers();
});
expect(dialog).not.toBeInTheDocument();
expect(onCancelSpy).toHaveBeenCalledTimes(1);
});

it('fires onCancel once when the cancel button is pressed', async function () {
let user = userEvent.setup({delay: null, pointerMap});
let onCancelSpy = jest.fn();
let {getByRole, getByText} = render(
<Provider theme={theme}>
<DialogTrigger>
<ActionButton>Trigger</ActionButton>
<AlertDialog
variant="confirmation"
title="the title"
primaryActionLabel="confirm"
cancelLabel="cancel"
onCancel={onCancelSpy}>
Content body
</AlertDialog>
</DialogTrigger>
</Provider>
);

let button = getByRole('button');
await user.click(button);
act(() => {
jest.runAllTimers();
});

let dialog = getByRole('alertdialog');

await user.click(getByText('cancel'));
act(() => {
jest.runAllTimers();
});
expect(dialog).not.toBeInTheDocument();
expect(onCancelSpy).toHaveBeenCalledTimes(1);
});
});
});
31 changes: 30 additions & 1 deletion packages/@react-spectrum/s2/src/AlertDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,11 @@ import {chain} from 'react-aria/chain';
import {Content, Heading} from './Content';
import {Dialog} from './Dialog';
import {filterDOMProps} from 'react-aria/filterDOMProps';
import {forwardRef, ReactNode} from 'react';
import {forwardRef, KeyboardEvent as ReactKeyboardEvent, ReactNode, useContext} from 'react';
import {IconContext} from './Icon';
import intlMessages from '../intl/*.json';
import NoticeSquare from '../s2wf-icons/S2_Icon_AlertDiamond_20_N.svg';
import {OverlayTriggerStateContext} from 'react-aria-components/Dialog';
import {Provider} from 'react-aria-components/slots';
import {style} from '../style' with {type: 'macro'};
import {UnsafeStyles} from './style-utils' with {type: 'macro'};
Expand Down Expand Up @@ -108,9 +109,37 @@ export const AlertDialog = forwardRef(function AlertDialog(props: AlertDialogPro

let domProps = filterDOMProps(props, {labelable: true});

// The Escape key is normally handled by the modal overlay, which closes the dialog
// without pressing the cancel button. Handle it on the dialog itself instead, so that
// dismissing an AlertDialog with the Escape key is equivalent to pressing the cancel button.
let state = useContext(OverlayTriggerStateContext);
let onKeyDown = (e: ReactKeyboardEvent) => {
if (
e.key === 'Escape' &&
!e.nativeEvent.isComposing &&
!e.nativeEvent.repeat &&
!e.altKey &&
!e.ctrlKey &&
!e.shiftKey &&
!e.metaKey
) {
if (state) {
state.close();
onCancel();
e.stopPropagation();
e.preventDefault();
} else {
// Within a DialogContainer there is no trigger state at this point in the tree.
// Let the event propagate so the overlay closes the dialog, and only fire onCancel.
onCancel();
}
}
};

return (
<Dialog
{...domProps}
onKeyDown={onKeyDown}
role="alertdialog"
ref={ref}
size={props.size}
Expand Down
6 changes: 4 additions & 2 deletions packages/@react-spectrum/s2/src/Dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ import {ButtonGroupContext} from './ButtonGroup';
import {CloseButton} from './CloseButton';
import {composeRenderProps} from 'react-aria-components/composeRenderProps';
import {ContentContext, FooterContext, HeaderContext, HeadingContext} from './Content';
import {DOMRef, GlobalDOMAttributes} from '@react-types/shared';
import {forwardRef} from 'react';
import {DOMRef, FocusableElement, GlobalDOMAttributes} from '@react-types/shared';
import {forwardRef, KeyboardEventHandler} from 'react';
import {ImageContext} from './Image';
import {Modal} from './Modal';
import {
Expand Down Expand Up @@ -46,6 +46,8 @@ export interface DialogProps
size?: 'S' | 'M' | 'L' | 'XL';
/** Whether pressing the escape key to close the dialog should be disabled. */
isKeyboardDismissDisabled?: boolean;
/** @private */
onKeyDown?: KeyboardEventHandler<FocusableElement>;
}

const image = style({
Expand Down
87 changes: 87 additions & 0 deletions packages/@react-spectrum/s2/test/AlertDialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,4 +82,91 @@ describe('AlertDialog', () => {
let content = document.getElementById(description!);
expect(content).toHaveTextContent('Test content');
});

it('fires onCancel once and closes the dialog when the Escape key is pressed', async () => {
let onCancelSpy = jest.fn();
let {getByRole} = render(
<DialogTrigger>
<ActionButton>Open dialog</ActionButton>
<AlertDialog
title="Test"
primaryActionLabel="Confirm"
cancelLabel="Cancel"
onCancel={onCancelSpy}>
Test content
</AlertDialog>
</DialogTrigger>
);

let trigger = getByRole('button');
await user.click(trigger);
act(() => {
jest.runAllTimers();
});
let dialog = getByRole('alertdialog');
expect(dialog).toBeVisible();

await user.keyboard('{Escape}');
act(() => {
jest.runAllTimers();
});
expect(dialog).not.toBeInTheDocument();
expect(onCancelSpy).toHaveBeenCalledTimes(1);
});

it('closes the dialog on Escape when onCancel is not provided', async () => {
let {getByRole} = render(
<DialogTrigger>
<ActionButton>Open dialog</ActionButton>
<AlertDialog title="Test" primaryActionLabel="Confirm">
Test content
</AlertDialog>
</DialogTrigger>
);

let trigger = getByRole('button');
await user.click(trigger);
act(() => {
jest.runAllTimers();
});
let dialog = getByRole('alertdialog');
expect(dialog).toBeVisible();

await user.keyboard('{Escape}');
act(() => {
jest.runAllTimers();
});
expect(dialog).not.toBeInTheDocument();
});

it('fires onCancel once when the cancel button is pressed', async () => {
let onCancelSpy = jest.fn();
let {getByRole, getByText} = render(
<DialogTrigger>
<ActionButton>Open dialog</ActionButton>
<AlertDialog
title="Test"
primaryActionLabel="Confirm"
cancelLabel="Cancel"
onCancel={onCancelSpy}>
Test content
</AlertDialog>
</DialogTrigger>
);

let trigger = getByRole('button');
await user.click(trigger);
act(() => {
jest.runAllTimers();
});
let dialog = getByRole('alertdialog');
expect(dialog).toBeVisible();

await user.click(getByText('Cancel'));
act(() => {
jest.runAllTimers();
});
expect(dialog).not.toBeInTheDocument();
expect(onCancelSpy).toHaveBeenCalledTimes(1);
});
});
15 changes: 15 additions & 0 deletions packages/react-aria-components/test/Dialog.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,21 @@ describe('Dialog', () => {
expect(dialog).toHaveAttribute('data-rac');
});

it('should support onKeyDown on the dialog element', async () => {
let onKeyDown = jest.fn();
let {getByRole} = render(
<Dialog onKeyDown={onKeyDown}>
<Heading slot="title">Title</Heading>
</Dialog>
);

let dialog = getByRole('dialog');
act(() => dialog.focus());
await user.keyboard('{Enter}');
expect(onKeyDown).toHaveBeenCalledTimes(1);
expect(onKeyDown).toHaveBeenCalledWith(expect.objectContaining({key: 'Enter'}));
});

it('works with modal', async () => {
let {getByRole} = render(
<DialogTrigger>
Expand Down
5 changes: 4 additions & 1 deletion packages/react-aria/src/dialog/useDialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {
import {filterDOMProps} from '../utils/filterDOMProps';
import {focusSafely} from '../interactions/focusSafely';
import {getActiveElement, isFocusWithin} from '../utils/shadowdom/DOMFunctions';
import {useEffect, useRef} from 'react';
import {KeyboardEventHandler, useEffect, useRef} from 'react';
import {useOverlayFocusContain} from '../overlays/Overlay';
import {useSlotId} from '../utils/useId';

Expand All @@ -31,6 +31,8 @@ export interface AriaDialogProps extends DOMProps, AriaLabelingProps {
* @default 'dialog'
*/
role?: 'dialog' | 'alertdialog';
/** Handler that is called when a key is pressed while focus is inside the dialog. */
onKeyDown?: KeyboardEventHandler<FocusableElement>;
}

export interface DialogAria {
Expand Down Expand Up @@ -125,6 +127,7 @@ export function useDialog(
tabIndex: -1,
'aria-labelledby': props['aria-labelledby'] ?? titleId,
'aria-describedby': ariaDescribedby,
onKeyDown: props.onKeyDown,
// Prevent blur events from reaching useOverlay, which may cause
// popovers to close. Since focus is contained within the dialog,
// we don't want this to occur due to the above useEffect.
Expand Down