From 49d0a01f8ecaec8491ed3c0054531ce6de81a3a3 Mon Sep 17 00:00:00 2001 From: arham766 Date: Tue, 7 Jul 2026 00:27:17 -0700 Subject: [PATCH 1/2] fix(dialog): fire onCancel when AlertDialog is dismissed via Escape key The Escape key is handled by the overlay, which closes the dialog directly and bypasses the cancel button, so onCancel was never called. Provide an onKeyDown handler on the dialog element so onCancel is also fired when the Escape key dismisses the dialog, in both the v3 and S2 AlertDialogs. Closes #1773 --- .../react-spectrum/src/dialog/AlertDialog.tsx | 107 ++++++------ .../react-spectrum/src/dialog/Dialog.tsx | 11 +- .../test/dialog/AlertDialog.test.js | 155 +++++++++++++++++- .../@react-spectrum/s2/src/AlertDialog.tsx | 12 +- packages/@react-spectrum/s2/src/Dialog.tsx | 18 +- .../s2/test/AlertDialog.test.tsx | 87 ++++++++++ 6 files changed, 339 insertions(+), 51 deletions(-) diff --git a/packages/@adobe/react-spectrum/src/dialog/AlertDialog.tsx b/packages/@adobe/react-spectrum/src/dialog/AlertDialog.tsx index 8be387d4a8a..fb1cc4c32ec 100644 --- a/packages/@adobe/react-spectrum/src/dialog/AlertDialog.tsx +++ b/packages/@adobe/react-spectrum/src/dialog/AlertDialog.tsx @@ -64,7 +64,8 @@ export const AlertDialog = forwardRef(function AlertDialog( props: SpectrumAlertDialogProps, ref: DOMRef ) { - let {onClose = () => {}} = useContext(DialogContext) || ({} as DialogContextValue); + let dialogContext = useContext(DialogContext) || ({} as DialogContextValue); + let {onClose = () => {}} = dialogContext; let { variant, @@ -93,54 +94,68 @@ export const AlertDialog = forwardRef(function AlertDialog( } } + // The Escape key is handled by the overlay, which closes the dialog without pressing + // the cancel button. Provide an onKeyDown handler through the context so that onCancel + // is also called when the Escape key dismisses the dialog. + let context: DialogContextValue = { + ...dialogContext, + onKeyDown: chain(dialogContext.onKeyDown, (e: React.KeyboardEvent) => { + if (e.key === 'Escape' && !e.nativeEvent.isComposing) { + onCancel(); + } + }) + }; + return ( - - {title} - {(variant === 'error' || variant === 'warning') && ( - - )} - - {children} - - {cancelLabel && ( - + + + {title} + {(variant === 'error' || variant === 'warning') && ( + + )} + + {children} + + {cancelLabel && ( + + )} + {secondaryActionLabel && ( + + )} - )} - - - + + + ); }); diff --git a/packages/@adobe/react-spectrum/src/dialog/Dialog.tsx b/packages/@adobe/react-spectrum/src/dialog/Dialog.tsx index a0b86e78a88..ff18e74ec2e 100644 --- a/packages/@adobe/react-spectrum/src/dialog/Dialog.tsx +++ b/packages/@adobe/react-spectrum/src/dialog/Dialog.tsx @@ -53,7 +53,11 @@ let sizeMap = { */ export const Dialog = React.forwardRef(function Dialog(props: SpectrumDialogProps, ref: DOMRef) { props = useSlotProps(props, 'dialog'); - let {type = 'modal', ...contextProps} = useContext(DialogContext) || ({} as DialogContextValue); + let { + type = 'modal', + onKeyDown: contextOnKeyDown, + ...contextProps + } = useContext(DialogContext) || ({} as DialogContextValue); let { children, isDismissable = contextProps.isDismissable, @@ -70,6 +74,11 @@ export const Dialog = React.forwardRef(function Dialog(props: SpectrumDialogProp let gridRef = useRef(null); let sizeVariant = sizeMap[type] || sizeMap[size]; let {dialogProps, titleProps, contentProps} = useDialog(mergeProps(contextProps, props), domRef); + if (contextOnKeyDown) { + // useDialog filters out event handlers, so merge the context's onKeyDown separately. + // This allows components like AlertDialog to respond to the Escape key dismissing the dialog. + dialogProps = mergeProps(dialogProps, {onKeyDown: contextOnKeyDown}); + } // oxlint-disable-next-line react/react-compiler let hasHeader = useHasChild(`.${styles['spectrum-Dialog-header']}`, unwrapDOMRef(gridRef)); diff --git a/packages/@adobe/react-spectrum/test/dialog/AlertDialog.test.js b/packages/@adobe/react-spectrum/test/dialog/AlertDialog.test.js index 0c840198996..cd2a7deb9ca 100644 --- a/packages/@adobe/react-spectrum/test/dialog/AlertDialog.test.js +++ b/packages/@adobe/react-spectrum/test/dialog/AlertDialog.test.js @@ -10,8 +10,16 @@ * governing permissions and limitations under the License. */ +import { + act, + pointerMap, + render, + simulateDesktop, + waitFor +} 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'; @@ -279,4 +287,149 @@ 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( + + + Content body + + + ); + + let dialog = getByRole('alertdialog'); + expect(document.activeElement).toBe(dialog); + + await user.keyboard('{Escape}'); + expect(onCancelSpy).toHaveBeenCalledTimes(1); + expect(onCancelSpy).toHaveBeenCalledWith(); + }); + + it('does not throw on Escape when onCancel is not provided', async function () { + let user = userEvent.setup({delay: null, pointerMap}); + let onPrimaryAction = jest.fn(); + let {getByRole} = render( + + + Content body + + + ); + + 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( + + + Trigger + + Content body + + + + ); + + let button = getByRole('button'); + await user.click(button); + act(() => { + jest.runAllTimers(); + }); + + let dialog = getByRole('alertdialog'); + await waitFor(() => { + expect(dialog).toBeVisible(); + }); + + await user.keyboard('{Escape}'); + act(() => { + jest.runAllTimers(); + }); + await waitFor(() => { + 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( + + + Trigger + + Content body + + + + ); + + let button = getByRole('button'); + await user.click(button); + act(() => { + jest.runAllTimers(); + }); + + let dialog = getByRole('alertdialog'); + await waitFor(() => { + expect(dialog).toBeVisible(); + }); + + await user.click(getByText('cancel')); + act(() => { + jest.runAllTimers(); + }); + await waitFor(() => { + expect(dialog).not.toBeInTheDocument(); + }); + expect(onCancelSpy).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/packages/@react-spectrum/s2/src/AlertDialog.tsx b/packages/@react-spectrum/s2/src/AlertDialog.tsx index 38d8ee5a0aa..8aa5b68ae0b 100644 --- a/packages/@react-spectrum/s2/src/AlertDialog.tsx +++ b/packages/@react-spectrum/s2/src/AlertDialog.tsx @@ -19,7 +19,7 @@ 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} from 'react'; import {IconContext} from './Icon'; import intlMessages from '../intl/*.json'; import NoticeSquare from '../s2wf-icons/S2_Icon_AlertDiamond_20_N.svg'; @@ -108,9 +108,19 @@ export const AlertDialog = forwardRef(function AlertDialog(props: AlertDialogPro let domProps = filterDOMProps(props, {labelable: true}); + // The Escape key is handled by the modal overlay, which closes the dialog without + // pressing the cancel button. Attach an onKeyDown handler to the dialog so that + // onCancel is also called when the Escape key dismisses the dialog. + let onKeyDown = (e: ReactKeyboardEvent) => { + if (e.key === 'Escape' && !e.nativeEvent.isComposing) { + onCancel(); + } + }; + return ( ; } const image = style({ @@ -110,7 +112,7 @@ export const dialogInner = style({ * Dialog is acknowledged. */ export const Dialog = forwardRef(function Dialog(props: DialogProps, ref: DOMRef) { - let {size = 'M', isDismissible, isKeyboardDismissDisabled} = props; + let {size = 'M', isDismissible, isKeyboardDismissDisabled, onKeyDown} = props; let domRef = useDOMRef(ref); return ( @@ -120,6 +122,18 @@ export const Dialog = forwardRef(function Dialog(props: DialogProps, ref: DOMRef isKeyboardDismissDisabled={isKeyboardDismissDisabled}> ( + // The dialog role is included in sectionProps. + // oxlint-disable-next-line jsx-a11y/no-static-element-interactions +
+ ) + : undefined + } ref={domRef} style={props.UNSAFE_style} className={(props.UNSAFE_className || '') + dialogInner}> diff --git a/packages/@react-spectrum/s2/test/AlertDialog.test.tsx b/packages/@react-spectrum/s2/test/AlertDialog.test.tsx index 06a8f2d3fb8..cdb7eaabcb8 100644 --- a/packages/@react-spectrum/s2/test/AlertDialog.test.tsx +++ b/packages/@react-spectrum/s2/test/AlertDialog.test.tsx @@ -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( + + Open dialog + + Test content + + + ); + + 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( + + Open dialog + + Test content + + + ); + + 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( + + Open dialog + + Test content + + + ); + + 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); + }); }); From 925e32790b84d63e784ab56a504d2714f81e6afd Mon Sep 17 00:00:00 2001 From: arham766 Date: Tue, 7 Jul 2026 17:18:06 -0700 Subject: [PATCH 2/2] address review feedback: escape equals cancel, useDialog onKeyDown --- .../react-spectrum/src/dialog/AlertDialog.tsx | 124 +++++++++--------- .../react-spectrum/src/dialog/Dialog.tsx | 11 +- .../test/dialog/AlertDialog.test.js | 24 +--- .../@react-spectrum/s2/src/AlertDialog.tsx | 31 ++++- packages/@react-spectrum/s2/src/Dialog.tsx | 18 +-- .../react-aria-components/test/Dialog.test.js | 15 +++ packages/react-aria/src/dialog/useDialog.ts | 5 +- 7 files changed, 117 insertions(+), 111 deletions(-) diff --git a/packages/@adobe/react-spectrum/src/dialog/AlertDialog.tsx b/packages/@adobe/react-spectrum/src/dialog/AlertDialog.tsx index fb1cc4c32ec..ff7394c0276 100644 --- a/packages/@adobe/react-spectrum/src/dialog/AlertDialog.tsx +++ b/packages/@adobe/react-spectrum/src/dialog/AlertDialog.tsx @@ -64,8 +64,7 @@ export const AlertDialog = forwardRef(function AlertDialog( props: SpectrumAlertDialogProps, ref: DOMRef ) { - let dialogContext = useContext(DialogContext) || ({} as DialogContextValue); - let {onClose = () => {}} = dialogContext; + let {onClose = () => {}} = useContext(DialogContext) || ({} as DialogContextValue); let { variant, @@ -94,68 +93,75 @@ export const AlertDialog = forwardRef(function AlertDialog( } } - // The Escape key is handled by the overlay, which closes the dialog without pressing - // the cancel button. Provide an onKeyDown handler through the context so that onCancel - // is also called when the Escape key dismisses the dialog. - let context: DialogContextValue = { - ...dialogContext, - onKeyDown: chain(dialogContext.onKeyDown, (e: React.KeyboardEvent) => { - if (e.key === 'Escape' && !e.nativeEvent.isComposing) { - onCancel(); - } - }) + // 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 ( - - - {title} - {(variant === 'error' || variant === 'warning') && ( - + + {title} + {(variant === 'error' || variant === 'warning') && ( + + )} + + {children} + + {cancelLabel && ( + )} - - {children} - - {cancelLabel && ( - - )} - {secondaryActionLabel && ( - - )} + {secondaryActionLabel && ( - - - + )} + + +
); }); diff --git a/packages/@adobe/react-spectrum/src/dialog/Dialog.tsx b/packages/@adobe/react-spectrum/src/dialog/Dialog.tsx index ff18e74ec2e..a0b86e78a88 100644 --- a/packages/@adobe/react-spectrum/src/dialog/Dialog.tsx +++ b/packages/@adobe/react-spectrum/src/dialog/Dialog.tsx @@ -53,11 +53,7 @@ let sizeMap = { */ export const Dialog = React.forwardRef(function Dialog(props: SpectrumDialogProps, ref: DOMRef) { props = useSlotProps(props, 'dialog'); - let { - type = 'modal', - onKeyDown: contextOnKeyDown, - ...contextProps - } = useContext(DialogContext) || ({} as DialogContextValue); + let {type = 'modal', ...contextProps} = useContext(DialogContext) || ({} as DialogContextValue); let { children, isDismissable = contextProps.isDismissable, @@ -74,11 +70,6 @@ export const Dialog = React.forwardRef(function Dialog(props: SpectrumDialogProp let gridRef = useRef(null); let sizeVariant = sizeMap[type] || sizeMap[size]; let {dialogProps, titleProps, contentProps} = useDialog(mergeProps(contextProps, props), domRef); - if (contextOnKeyDown) { - // useDialog filters out event handlers, so merge the context's onKeyDown separately. - // This allows components like AlertDialog to respond to the Escape key dismissing the dialog. - dialogProps = mergeProps(dialogProps, {onKeyDown: contextOnKeyDown}); - } // oxlint-disable-next-line react/react-compiler let hasHeader = useHasChild(`.${styles['spectrum-Dialog-header']}`, unwrapDOMRef(gridRef)); diff --git a/packages/@adobe/react-spectrum/test/dialog/AlertDialog.test.js b/packages/@adobe/react-spectrum/test/dialog/AlertDialog.test.js index cd2a7deb9ca..5d314b7a580 100644 --- a/packages/@adobe/react-spectrum/test/dialog/AlertDialog.test.js +++ b/packages/@adobe/react-spectrum/test/dialog/AlertDialog.test.js @@ -10,13 +10,7 @@ * governing permissions and limitations under the License. */ -import { - act, - pointerMap, - render, - simulateDesktop, - waitFor -} from '@react-spectrum/test-utils-internal'; +import {act, pointerMap, render, simulateDesktop} from '@react-spectrum/test-utils-internal'; import {ActionButton} from '../../src/button/ActionButton'; import {AlertDialog} from '../../src/dialog/AlertDialog'; import {DialogTrigger} from '../../src/dialog/DialogTrigger'; @@ -312,7 +306,7 @@ describe('AlertDialog', function () { expect(onCancelSpy).toHaveBeenCalledWith(); }); - it('does not throw on Escape when onCancel is not provided', async function () { + 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( @@ -378,17 +372,12 @@ describe('AlertDialog', function () { }); let dialog = getByRole('alertdialog'); - await waitFor(() => { - expect(dialog).toBeVisible(); - }); await user.keyboard('{Escape}'); act(() => { jest.runAllTimers(); }); - await waitFor(() => { - expect(dialog).not.toBeInTheDocument(); - }); + expect(dialog).not.toBeInTheDocument(); expect(onCancelSpy).toHaveBeenCalledTimes(1); }); @@ -418,17 +407,12 @@ describe('AlertDialog', function () { }); let dialog = getByRole('alertdialog'); - await waitFor(() => { - expect(dialog).toBeVisible(); - }); await user.click(getByText('cancel')); act(() => { jest.runAllTimers(); }); - await waitFor(() => { - expect(dialog).not.toBeInTheDocument(); - }); + expect(dialog).not.toBeInTheDocument(); expect(onCancelSpy).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/@react-spectrum/s2/src/AlertDialog.tsx b/packages/@react-spectrum/s2/src/AlertDialog.tsx index 8aa5b68ae0b..8de3587da89 100644 --- a/packages/@react-spectrum/s2/src/AlertDialog.tsx +++ b/packages/@react-spectrum/s2/src/AlertDialog.tsx @@ -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, KeyboardEvent as ReactKeyboardEvent, 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'}; @@ -108,12 +109,30 @@ export const AlertDialog = forwardRef(function AlertDialog(props: AlertDialogPro let domProps = filterDOMProps(props, {labelable: true}); - // The Escape key is handled by the modal overlay, which closes the dialog without - // pressing the cancel button. Attach an onKeyDown handler to the dialog so that - // onCancel is also called when the Escape key dismisses the dialog. + // 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) { - onCancel(); + 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(); + } } }; diff --git a/packages/@react-spectrum/s2/src/Dialog.tsx b/packages/@react-spectrum/s2/src/Dialog.tsx index caa30b9c61b..a2915884879 100644 --- a/packages/@react-spectrum/s2/src/Dialog.tsx +++ b/packages/@react-spectrum/s2/src/Dialog.tsx @@ -14,7 +14,7 @@ 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 {DOMRef, FocusableElement, GlobalDOMAttributes} from '@react-types/shared'; import {forwardRef, KeyboardEventHandler} from 'react'; import {ImageContext} from './Image'; import {Modal} from './Modal'; @@ -47,7 +47,7 @@ export interface DialogProps /** Whether pressing the escape key to close the dialog should be disabled. */ isKeyboardDismissDisabled?: boolean; /** @private */ - onKeyDown?: KeyboardEventHandler; + onKeyDown?: KeyboardEventHandler; } const image = style({ @@ -112,7 +112,7 @@ export const dialogInner = style({ * Dialog is acknowledged. */ export const Dialog = forwardRef(function Dialog(props: DialogProps, ref: DOMRef) { - let {size = 'M', isDismissible, isKeyboardDismissDisabled, onKeyDown} = props; + let {size = 'M', isDismissible, isKeyboardDismissDisabled} = props; let domRef = useDOMRef(ref); return ( @@ -122,18 +122,6 @@ export const Dialog = forwardRef(function Dialog(props: DialogProps, ref: DOMRef isKeyboardDismissDisabled={isKeyboardDismissDisabled}> ( - // The dialog role is included in sectionProps. - // oxlint-disable-next-line jsx-a11y/no-static-element-interactions -
- ) - : undefined - } ref={domRef} style={props.UNSAFE_style} className={(props.UNSAFE_className || '') + dialogInner}> diff --git a/packages/react-aria-components/test/Dialog.test.js b/packages/react-aria-components/test/Dialog.test.js index 1ea070282cf..31442a0137b 100644 --- a/packages/react-aria-components/test/Dialog.test.js +++ b/packages/react-aria-components/test/Dialog.test.js @@ -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( + + Title + + ); + + 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( diff --git a/packages/react-aria/src/dialog/useDialog.ts b/packages/react-aria/src/dialog/useDialog.ts index 1a7a021a9a5..932fd07d49e 100644 --- a/packages/react-aria/src/dialog/useDialog.ts +++ b/packages/react-aria/src/dialog/useDialog.ts @@ -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'; @@ -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; } export interface DialogAria { @@ -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.