Skip to content
109 changes: 63 additions & 46 deletions packages/@adobe/react-spectrum/src/dialog/AlertDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,8 @@ export const AlertDialog = forwardRef(function AlertDialog(
props: SpectrumAlertDialogProps,
ref: DOMRef
) {
let {onClose = () => {}} = useContext(DialogContext) || ({} as DialogContextValue);
let outerContext = useContext(DialogContext) || ({} as DialogContextValue);
let {onClose = () => {}} = outerContext;

let {
variant,
Expand Down Expand Up @@ -94,54 +95,70 @@ export const AlertDialog = forwardRef(function AlertDialog(
}
}

// Provide a context that forwards the onCancel callback when Escape dismisses the dialog.
// The Escape key is handled by the overlay (Modal/Tray), which calls state.close() directly
// and bypasses the cancel button's onPress handler. By injecting an onKeyDown into the
// dialog's context we ensure onCancel fires for keyboard-initiated dismissals too.
let contextWithEscapeCancel: DialogContextValue = {
...outerContext,
onKeyDown: e => {
if (e.key === 'Escape' && props.onCancel) {
props.onCancel();
}
outerContext.onKeyDown?.(e);
}
};

return (
<Dialog
UNSAFE_style={styleProps.style}
UNSAFE_className={classNames(
styles,
{[`spectrum-Dialog--${variant}`]: variant},
styleProps.className
)}
isHidden={styleProps.hidden}
size="M"
role="alertdialog"
ref={ref}
{...filterDOMProps(props)}>
<Heading>{title}</Heading>
{(variant === 'error' || variant === 'warning') && (
<AlertMedium slot="typeIcon" aria-label={stringFormatter.format('alert')} />
)}
<Divider />
<Content>{children}</Content>
<ButtonGroup align="end">
{cancelLabel && (
<Button
variant="secondary"
onPress={() => chain(onClose(), onCancel())}
autoFocus={autoFocusButton === 'cancel'}
data-testid="rsp-AlertDialog-cancelButton">
{cancelLabel}
</Button>
<DialogContext.Provider value={contextWithEscapeCancel}>
<Dialog
UNSAFE_style={styleProps.style}
UNSAFE_className={classNames(
styles,
{[`spectrum-Dialog--${variant}`]: variant},
styleProps.className
)}
isHidden={styleProps.hidden}
size="M"
role="alertdialog"
ref={ref}
{...filterDOMProps(props)}>
<Heading>{title}</Heading>
{(variant === 'error' || variant === 'warning') && (
<AlertMedium slot="typeIcon" aria-label={stringFormatter.format('alert')} />
)}
{secondaryActionLabel && (
<Divider />
<Content>{children}</Content>
<ButtonGroup align="end">
{cancelLabel && (
<Button
variant="secondary"
onPress={() => chain(onClose(), onCancel())}
autoFocus={autoFocusButton === 'cancel'}
data-testid="rsp-AlertDialog-cancelButton">
{cancelLabel}
</Button>
)}
{secondaryActionLabel && (
<Button
variant="secondary"
onPress={() => chain(onClose(), onSecondaryAction())}
isDisabled={isSecondaryActionDisabled}
autoFocus={autoFocusButton === 'secondary'}
data-testid="rsp-AlertDialog-secondaryButton">
{secondaryActionLabel}
</Button>
)}
<Button
variant="secondary"
onPress={() => chain(onClose(), onSecondaryAction())}
isDisabled={isSecondaryActionDisabled}
autoFocus={autoFocusButton === 'secondary'}
data-testid="rsp-AlertDialog-secondaryButton">
{secondaryActionLabel}
variant={confirmVariant}
onPress={() => chain(onClose(), onPrimaryAction())}
isDisabled={isPrimaryActionDisabled}
autoFocus={autoFocusButton === 'primary'}
data-testid="rsp-AlertDialog-confirmButton">
{primaryActionLabel}
</Button>
)}
<Button
variant={confirmVariant}
onPress={() => chain(onClose(), onPrimaryAction())}
isDisabled={isPrimaryActionDisabled}
autoFocus={autoFocusButton === 'primary'}
data-testid="rsp-AlertDialog-confirmButton">
{primaryActionLabel}
</Button>
</ButtonGroup>
</Dialog>
</ButtonGroup>
</Dialog>
</DialogContext.Provider>
);
});
9 changes: 8 additions & 1 deletion packages/@adobe/react-spectrum/src/dialog/Dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,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,
Expand All @@ -72,6 +76,9 @@ export const Dialog = React.forwardRef(function Dialog(props: SpectrumDialogProp
let gridRef = useRef(null);
let sizeVariant = sizeMap[type] || sizeMap[size];
let {dialogProps, titleProps} = useDialog(mergeProps(contextProps, props), domRef);
if (contextOnKeyDown) {
dialogProps = mergeProps(dialogProps, {onKeyDown: contextOnKeyDown});
}

let hasHeader = useHasChild(`.${styles['spectrum-Dialog-header']}`, unwrapDOMRef(gridRef));
let hasHeading = useHasChild(`.${styles['spectrum-Dialog-heading']}`, unwrapDOMRef(gridRef));
Expand Down
46 changes: 46 additions & 0 deletions packages/@adobe/react-spectrum/test/dialog/AlertDialog.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -246,4 +246,50 @@ describe('AlertDialog', function () {
let primaryBtn = getByTestId('rsp-AlertDialog-confirmButton');
expect(primaryBtn).toBeDefined();
});

it('fires onCancel when 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);
});

it('does not fire onCancel on Escape when onCancel prop 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);

// Should not throw or call anything unexpected
await user.keyboard('{Escape}');
expect(onPrimaryAction).toHaveBeenCalledTimes(0);
});
});