diff --git a/packages/@react-aria/overlays/src/useOverlay.ts b/packages/@react-aria/overlays/src/useOverlay.ts index 3771d66f049..64fdd172b4d 100644 --- a/packages/@react-aria/overlays/src/useOverlay.ts +++ b/packages/@react-aria/overlays/src/useOverlay.ts @@ -16,7 +16,9 @@ import {useInteractOutside} from '@react-aria/interactions'; interface OverlayProps { ref: RefObject, onClose?: () => void, - isOpen?: boolean + isOpen?: boolean, + // Whether to close overlay if underlay is clicked + isDismissable?: boolean } interface OverlayAria { @@ -26,7 +28,7 @@ interface OverlayAria { const visibleOverlays: RefObject[] = []; export function useOverlay(props: OverlayProps): OverlayAria { - let {ref, onClose, isOpen} = props; + let {ref, onClose, isOpen, isDismissable = false} = props; // Add the overlay ref to the stack of visible overlays on mount, and remove on unmount. useEffect(() => { @@ -58,7 +60,7 @@ export function useOverlay(props: OverlayProps): OverlayAria { }; // Handle clicking outside the overlay to close it - useInteractOutside({ref, onInteractOutside: onHide}); + useInteractOutside({ref, onInteractOutside: isDismissable ? onHide : null}); return { overlayProps: { diff --git a/packages/@react-aria/overlays/test/useOverlay.test.js b/packages/@react-aria/overlays/test/useOverlay.test.js index f1fb2cc7e69..393e5386ac5 100644 --- a/packages/@react-aria/overlays/test/useOverlay.test.js +++ b/packages/@react-aria/overlays/test/useOverlay.test.js @@ -34,13 +34,20 @@ describe('useOverlay', function () { expect(document.activeElement).toBe(input); }); - it('should hide the overlay when clicking outside', function () { + it('should hide the overlay when clicking outside if isDismissble is true', function () { let onClose = jest.fn(); - render(); + render(); fireEvent.mouseUp(document.body); expect(onClose).toHaveBeenCalledTimes(1); }); + it('should not hide the overlay when clicking outside if isDismissable is false', function () { + let onClose = jest.fn(); + render(); + fireEvent.mouseUp(document.body); + expect(onClose).toHaveBeenCalledTimes(0); + }); + it('should hide the overlay when pressing the escape key', function () { let onClose = jest.fn(); let res = render(); @@ -49,11 +56,19 @@ describe('useOverlay', function () { expect(onClose).toHaveBeenCalledTimes(1); }); + it('should still hide the overlay when pressing the escape key if isDismissable is false', function () { + let onClose = jest.fn(); + let res = render(); + let el = res.getByTestId('test'); + fireEvent.keyDown(el, {key: 'Escape'}); + expect(onClose).toHaveBeenCalledTimes(1); + }); + it('should only hide the top-most overlay', function () { let onCloseFirst = jest.fn(); let onCloseSecond = jest.fn(); - render(); - let second = render(); + render(); + let second = render(); fireEvent.mouseUp(document.body); expect(onCloseSecond).toHaveBeenCalledTimes(1); diff --git a/packages/@react-spectrum/dialog/src/AlertDialog.tsx b/packages/@react-spectrum/dialog/src/AlertDialog.tsx index aa19850eb0e..ee22d73678d 100644 --- a/packages/@react-spectrum/dialog/src/AlertDialog.tsx +++ b/packages/@react-spectrum/dialog/src/AlertDialog.tsx @@ -12,17 +12,23 @@ import AlertMedium from '@spectrum-icons/ui/AlertMedium'; import {Button} from '@react-spectrum/button'; +import {chain} from '@react-aria/utils'; import {classNames, useStyleProps} from '@react-spectrum/utils'; import {Content, Footer, Header} from '@react-spectrum/view'; import {Dialog} from './Dialog'; +import {DialogContext, DialogContextValue} from './context'; import {Divider} from '@react-spectrum/divider'; import {Heading} from '@react-spectrum/typography'; -import React from 'react'; +import React, {useContext} from 'react'; import {SpectrumAlertDialogProps} from '@react-types/dialog'; import {SpectrumButtonProps} from '@react-types/button'; import styles from '@adobe/spectrum-css-temp/components/dialog/vars.css'; export function AlertDialog(props: SpectrumAlertDialogProps) { + let { + onClose = () => {} + } = useContext(DialogContext) || {} as DialogContextValue; + let { variant, children, @@ -32,8 +38,8 @@ export function AlertDialog(props: SpectrumAlertDialogProps) { autoFocusButton, title, isConfirmDisabled, - onCancel, - onConfirm, + onCancel = () => {}, + onConfirm = () => {}, ...otherProps } = props; let {styleProps} = useStyleProps(otherProps); @@ -53,9 +59,9 @@ export function AlertDialog(props: SpectrumAlertDialogProps) { {children}
- {secondaryLabel && } - {cancelLabel && } - + {secondaryLabel && } + {cancelLabel && } +
); diff --git a/packages/@react-spectrum/dialog/src/Dialog.tsx b/packages/@react-spectrum/dialog/src/Dialog.tsx index e933946ab77..563bfa2adcf 100644 --- a/packages/@react-spectrum/dialog/src/Dialog.tsx +++ b/packages/@react-spectrum/dialog/src/Dialog.tsx @@ -29,8 +29,8 @@ export function Dialog(props: SpectrumDialogProps) { } = useContext(DialogContext) || {} as DialogContextValue; let { children, - isDismissable, - onDismiss, + isDismissable = contextProps.isDismissable, + onDismiss = contextProps.onClose, ...otherProps } = props; let {styleProps} = useStyleProps(otherProps); @@ -38,7 +38,7 @@ export function Dialog(props: SpectrumDialogProps) { mergeProps( mergeProps( filterDOMProps(otherProps), - contextProps + filterDOMProps(contextProps) ), styleProps ), @@ -56,7 +56,7 @@ export function Dialog(props: SpectrumDialogProps) { return ( {children} - {isDismissable && } onPress={onDismiss} />} + {isDismissable && } aria-label="dismiss" onPress={onDismiss} />} ); } diff --git a/packages/@react-spectrum/dialog/src/DialogTrigger.tsx b/packages/@react-spectrum/dialog/src/DialogTrigger.tsx index 450e4e37c4f..6779c9b3d86 100644 --- a/packages/@react-spectrum/dialog/src/DialogTrigger.tsx +++ b/packages/@react-spectrum/dialog/src/DialogTrigger.tsx @@ -14,8 +14,8 @@ import {DialogContext} from './context'; import {DOMRefValue} from '@react-types/shared'; import {Modal, Overlay, Popover, Tray} from '@react-spectrum/overlays'; import {PressResponder} from '@react-aria/interactions'; -import React, {Fragment, useRef} from 'react'; -import {SpectrumDialogTriggerProps} from '@react-types/dialog'; +import React, {Fragment, ReactElement, useRef} from 'react'; +import {SpectrumDialogClose, SpectrumDialogProps, SpectrumDialogTriggerProps} from '@react-types/dialog'; import {unwrapDOMRef, useMediaQuery} from '@react-spectrum/utils'; import {useControlledState} from '@react-stately/utils'; import {useOverlayPosition, useOverlayTrigger} from '@react-aria/overlays'; @@ -27,9 +27,14 @@ export function DialogTrigger(props: SpectrumDialogTriggerProps) { mobileType = type === 'popover' ? 'modal' : type, hideArrow, targetRef, + isDismissable, ...positionProps } = props; - let [trigger, content] = React.Children.toArray(children); + if (!Array.isArray(children) || children.length > 2) { + throw new Error('DialogTrigger must have exactly 2 children'); + } + // if a function is passed as the second child, it won't appear in toArray + let [trigger, content] = children as [ReactElement, SpectrumDialogClose]; // On small devices, show a modal or tray instead of a popover. // TODO: DNA variable? @@ -66,20 +71,20 @@ export function DialogTrigger(props: SpectrumDialogTriggerProps) { case 'fullscreen': case 'fullscreenTakeover': return ( - - {content} + + {typeof content === 'function' ? content(onClose) : content} ); case 'modal': return ( - - {content} + + {typeof content === 'function' ? content(onClose) : content} ); case 'tray': return ( - {content} + {typeof content === 'function' ? content(onClose) : content} ); } @@ -91,6 +96,7 @@ export function DialogTrigger(props: SpectrumDialogTriggerProps) { isOpen={isOpen} onPress={onPress} onClose={onClose} + isDismissable={isDismissable} trigger={trigger} overlay={renderOverlay()} /> ); @@ -111,7 +117,7 @@ function PopoverTrigger({isOpen, onPress, onClose, targetRef, trigger, content, shouldFlip: props.shouldFlip, isOpen }); - + let {triggerAriaProps, overlayAriaProps} = useOverlayTrigger({ ref: triggerRef, type: 'dialog', @@ -145,10 +151,23 @@ function PopoverTrigger({isOpen, onPress, onClose, targetRef, trigger, content, ); } -function DialogTriggerBase({type, isOpen, onPress, onClose, dialogProps = {}, triggerProps = {}, overlay, trigger}) { +interface SpectrumDialogTriggerBase { + type?: 'modal' | 'popover' | 'tray' | 'fullscreen' | 'fullscreenTakeover', + isOpen?: boolean, + onPress?: any, + onClose?: () => void, + isDismissable?: boolean + dialogProps?: SpectrumDialogProps | {}, + triggerProps?: any, + overlay: ReactElement, + trigger: ReactElement +} + +function DialogTriggerBase({type, isOpen, onPress, onClose, isDismissable, dialogProps = {}, triggerProps = {}, overlay, trigger}: SpectrumDialogTriggerBase) { let context = { type, onClose, + isDismissable, ...dialogProps }; diff --git a/packages/@react-spectrum/dialog/src/context.ts b/packages/@react-spectrum/dialog/src/context.ts index 039903128e9..f1fab8acba3 100644 --- a/packages/@react-spectrum/dialog/src/context.ts +++ b/packages/@react-spectrum/dialog/src/context.ts @@ -13,7 +13,8 @@ import React, {HTMLAttributes} from 'react'; export interface DialogContextValue extends HTMLAttributes { - type: 'modal' | 'popover' | 'tray', + type: 'modal' | 'popover' | 'tray' | 'fullscreen' | 'fullscreenTakeover', + isDismissable?: boolean, onClose?: () => void } diff --git a/packages/@react-spectrum/dialog/stories/Dialog.stories.tsx b/packages/@react-spectrum/dialog/stories/Dialog.stories.tsx index 48a0eb22d48..58af596c782 100644 --- a/packages/@react-spectrum/dialog/stories/Dialog.stories.tsx +++ b/packages/@react-spectrum/dialog/stories/Dialog.stories.tsx @@ -36,7 +36,7 @@ storiesOf('Dialog', module) ) .add( 'isDismissable', - () => render({isDismissable: true, onDismiss: action('dismissed')}) + () => render({isDismissable: true}) ) .add( 'long content', @@ -48,7 +48,7 @@ storiesOf('Dialog', module) ) .add( 'with hero, isDimissable', - () => renderHero({isDismissable: true, onDismiss: action('dismissed')}) + () => renderHero({isDismissable: true}) ) .add( 'small', @@ -198,41 +198,45 @@ storiesOf('Dialog/Alert', module) }) ); -function render({width = 'auto', ...props}) { +function render({width = 'auto', isDismissable = undefined, ...props}) { return (
- + Trigger - -
The Heading
- - {singleParagraph()} -
- - -
-
+ {(close) => ( + +
The Heading
+ + {singleParagraph()} +
+ + +
+
+ )}
); } -function renderHero({width = 'auto', ...props}) { +function renderHero({width = 'auto', isDismissable = undefined, ...props}) { return (
- + Trigger - - -
The Heading
- - {singleParagraph()} -
- - -
-
+ {(close) => ( + + +
The Heading
+ + {singleParagraph()} +
+ + +
+
+ )}
); @@ -241,9 +245,9 @@ function renderHero({width = 'auto', ...props}) { function renderAlert({width = 'auto', ...props}: SpectrumAlertDialogProps) { return (
- + Trigger - +
); @@ -253,33 +257,35 @@ function renderAlert({width = 'auto', ...props}: SpectrumAlertDialogProps) { function renderWithForm({width = 'auto', ...props}) { return (
- + Trigger - -
The Heading
- - -
- - Acknowledge robot overlords - - Battery - Information Storage - Processor - Zoo stock - Emotional Translator - Bounty Hunter - Actor - Waterslide Tester - Psychiatrist - - -
-
- - -
-
+ {(close) => ( + +
The Heading
+ + +
+ + Acknowledge robot overlords + + Battery + Information Storage + Processor + Zoo stock + Emotional Translator + Bounty Hunter + Actor + Waterslide Tester + Psychiatrist + + +
+
+ + +
+
+ )}
); @@ -298,17 +304,19 @@ let fiveParagraphs = () => ( function renderLongContent({width = 'auto', ...props}) { return (
- + Trigger - -
The Heading
- - {fiveParagraphs()} -
- - -
-
+ {(close) => ( + +
The Heading
+ + {fiveParagraphs()} +
+ + +
+
+ )}
); @@ -317,18 +325,20 @@ function renderLongContent({width = 'auto', ...props}) { function renderWithThreeButtons({width = 'auto', ...props}) { return (
- + Trigger - -
The Heading
- - {singleParagraph()} -
- - - -
-
+ {(close) => ( + +
The Heading
+ + {singleParagraph()} +
+ + + +
+
+ )}
); @@ -339,21 +349,23 @@ function renderWithDividerInContent({width = 'auto', ...props}) {
Trigger - -
The Heading
- - - - Column number one. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. - - Column number two. Eleifend quam adipiscing vitae proin sagittis nisl. Diam donec adipiscing tristique risus. - - -
- - -
-
+ {(close) => ( + +
The Heading
+ + + + Column number one. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. + + Column number two. Eleifend quam adipiscing vitae proin sagittis nisl. Diam donec adipiscing tristique risus. + + +
+ + +
+
+ )}
); diff --git a/packages/@react-spectrum/dialog/stories/DialogTrigger.stories.tsx b/packages/@react-spectrum/dialog/stories/DialogTrigger.stories.tsx index 58af3bf3cfd..8504cec85c4 100644 --- a/packages/@react-spectrum/dialog/stories/DialogTrigger.stories.tsx +++ b/packages/@react-spectrum/dialog/stories/DialogTrigger.stories.tsx @@ -13,6 +13,7 @@ import {action} from '@storybook/addon-actions'; import {ActionButton, Button} from '@react-spectrum/button'; import {AlertDialog, Dialog, DialogTrigger} from '../'; +import {chain} from '@react-aria/utils'; import {Content, Footer, Header} from '@react-spectrum/view'; import {Divider} from '@react-spectrum/divider'; import {Heading, Text} from '@react-spectrum/typography'; @@ -39,6 +40,11 @@ storiesOf('DialogTrigger', module) () => render({type: 'modal'}), {chromaticProvider: {scales: ['medium'], height: 1000}} ) + .add( + 'type: modal isDismissable', + () => render({type: 'modal', isDismissable: true}), + {chromaticProvider: {scales: ['medium'], height: 1000}} + ) .add( 'type: fullscreen', () => render({type: 'fullscreen'}) @@ -222,14 +228,16 @@ storiesOf('DialogTrigger', module) function render({width = 'auto', ...props}) { return (
- + Trigger - -
The Heading
- - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin sit amet tristique risus. In sit amet suscipit lorem. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. In condimentum imperdiet metus non condimentum. Duis eu velit et quam accumsan tempus at id velit. Duis elementum elementum purus, id tempus mauris posuere a. Nunc vestibulum sapien pellentesque lectus commodo ornare. -
-
+ {(close) => ( + +
The Heading
+ + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin sit amet tristique risus. In sit amet suscipit lorem. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. In condimentum imperdiet metus non condimentum. Duis eu velit et quam accumsan tempus at id velit. Duis elementum elementum purus, id tempus mauris posuere a. Nunc vestibulum sapien pellentesque lectus commodo ornare. +
+
+ )}
); @@ -238,7 +246,7 @@ function render({width = 'auto', ...props}) { function renderPopover({width = 'auto', ...props}) { return (
- + Trigger
The Heading
@@ -253,11 +261,13 @@ function renderPopover({width = 'auto', ...props}) { function renderAlert({width = 'auto', ...props}) { return (
- + Trigger - - Fine! No, absolutely fine. It's not like I don't have, you know, ten thousand other test subjects begging me to help them escape. You know, it's not like this place is about to EXPLODE. - + {(close) => ( + + Fine! No, absolutely fine. It's not like I don't have, you know, ten thousand other test subjects begging me to help them escape. You know, it's not like this place is about to EXPLODE. + + )}
); diff --git a/packages/@react-spectrum/dialog/test/AlertDialog.test.js b/packages/@react-spectrum/dialog/test/AlertDialog.test.js new file mode 100644 index 00000000000..b466a99e763 --- /dev/null +++ b/packages/@react-spectrum/dialog/test/AlertDialog.test.js @@ -0,0 +1,143 @@ +/* + * Copyright 2020 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {AlertDialog} from '../'; +import {cleanup, render} from '@testing-library/react'; +import React from 'react'; +import {triggerPress} from '@react-spectrum/test-utils'; + + +describe('AlertDialog', function () { + afterEach(cleanup); + + it('renders alert dialog with onConfirm', function () { + let onConfirmSpy = jest.fn(); + let {getByRole} = render( + + Content body + + ); + + let dialog = getByRole('alertdialog'); + expect(document.activeElement).toBe(dialog); + + let button = getByRole('button'); + triggerPress(button); + expect(onConfirmSpy).toHaveBeenCalledTimes(1); + expect(onConfirmSpy).toHaveBeenCalledWith('primary'); + }); + + it('renders 2 button alert dialog with onConfirm / onCancel', function () { + let onCancelSpy = jest.fn(); + let onConfirmSpy = jest.fn(); + let {getByRole, getByText} = render( + + Content body + + ); + + let dialog = getByRole('alertdialog'); + expect(document.activeElement).toBe(dialog); + + let cancelButton = getByText('cancel'); + triggerPress(cancelButton); + expect(onConfirmSpy).toHaveBeenCalledTimes(0); + expect(onCancelSpy).toHaveBeenCalledTimes(1); + expect(onCancelSpy).toHaveBeenCalledWith(); + + let confirmButton = getByText('confirm'); + triggerPress(confirmButton); + expect(onConfirmSpy).toHaveBeenCalledTimes(1); + expect(onCancelSpy).toHaveBeenCalledTimes(1); + expect(onConfirmSpy).toHaveBeenCalledWith('primary'); + }); + + it('renders a 3 button alert dialog with onConfirm / onCancel', function () { + let onCancelSpy = jest.fn(); + let onConfirmSpy = jest.fn(); + let {getByRole, getByText} = render( + + Content body + + ); + + let dialog = getByRole('alertdialog'); + expect(document.activeElement).toBe(dialog); + + let confirmButton = getByText('confirm'); + let secondaryButton = getByText('secondary'); + let cancelButton = getByText('cancel'); + triggerPress(secondaryButton); + expect(onConfirmSpy).toHaveBeenCalledTimes(1); + expect(onConfirmSpy).toHaveBeenLastCalledWith('secondary'); + expect(onCancelSpy).toHaveBeenCalledTimes(0); + + triggerPress(confirmButton); + expect(onConfirmSpy).toHaveBeenCalledTimes(2); + expect(onConfirmSpy).toHaveBeenLastCalledWith('primary'); + expect(onCancelSpy).toHaveBeenCalledTimes(0); + + triggerPress(cancelButton); + expect(onConfirmSpy).toHaveBeenCalledTimes(2); + expect(onCancelSpy).toHaveBeenCalledTimes(1); + expect(onCancelSpy).toHaveBeenLastCalledWith(); + }); + + it('disable its confirm button', function () { + let onConfirmSpy = jest.fn(); + let {getByRole, getByText} = render( + + Content body + + ); + + let dialog = getByRole('alertdialog'); + expect(document.activeElement).toBe(dialog); + + let button = getByText('confirm'); + triggerPress(button); + expect(onConfirmSpy).toHaveBeenCalledTimes(0); + }); + + it('autofocus its confirm button', function () { + let {getByText} = render( + + Content body + + ); + + let button = getByText('confirm').closest('button'); + expect(document.activeElement).toBe(button); + }); + + it('autofocus its cancel button', function () { + let {getByText} = render( + + Content body + + ); + + let button = getByText('cancel').closest('button'); + expect(document.activeElement).toBe(button); + }); + + it('autofocus its secondary button', function () { + let {getByText} = render( + + Content body + + ); + + let button = getByText('secondary').closest('button'); + expect(document.activeElement).toBe(button); + }); +}); diff --git a/packages/@react-spectrum/dialog/test/DialogTrigger.test.js b/packages/@react-spectrum/dialog/test/DialogTrigger.test.js index c5ded8e15f5..2617ec35bd0 100644 --- a/packages/@react-spectrum/dialog/test/DialogTrigger.test.js +++ b/packages/@react-spectrum/dialog/test/DialogTrigger.test.js @@ -10,9 +10,10 @@ * governing permissions and limitations under the License. */ -import {ActionButton} from '@react-spectrum/button'; +import {ActionButton, Button} from '@react-spectrum/button'; import {cleanup, fireEvent, render, waitForDomChange} from '@testing-library/react'; import {Dialog, DialogTrigger} from '../'; +import MatchMediaMock from 'jest-matchmedia-mock'; import {Provider} from '@react-spectrum/provider'; import React from 'react'; import scaleMedium from '@adobe/spectrum-css-temp/vars/spectrum-medium-unique.css'; @@ -25,14 +26,17 @@ let theme = { }; describe('DialogTrigger', function () { - afterEach(cleanup); + let matchMedia; beforeEach(() => { + matchMedia = new MatchMediaMock(); jest.spyOn(window, 'requestAnimationFrame').mockImplementation(cb => cb()); }); afterEach(() => { + matchMedia.clear(); window.requestAnimationFrame.mockRestore(); + cleanup(); }); it('should trigger a modal by default', function () { @@ -109,14 +113,7 @@ describe('DialogTrigger', function () { }); it('should trigger a modal instead of a popover on mobile', function () { - window.matchMedia = jest.fn().mockImplementation(query => ({ - matches: true, - media: query, - onchange: null, - addListener: jest.fn(), - removeListener: jest.fn() - })); - + matchMedia.useMediaQuery('(max-width: 700px)'); let {getByRole, getByTestId} = render( @@ -141,14 +138,7 @@ describe('DialogTrigger', function () { }); it('should trigger a tray instead of a popover on mobile if mobileType="tray"', function () { - window.matchMedia = jest.fn().mockImplementation(query => ({ - matches: true, - media: query, - onchange: null, - addListener: jest.fn(), - removeListener: jest.fn() - })); - + matchMedia.useMediaQuery('(max-width: 700px)'); let {getByRole, getByTestId} = render( @@ -298,4 +288,153 @@ describe('DialogTrigger', function () { getByRole('dialog'); }).toThrow(); }); + + it('can be closed by buttons the user adds', async function () { + function Test({defaultOpen, onOpenChange}) { + return ( + + + Trigger + {(close) => contents} + + + ); + } + + let onOpenChange = jest.fn(); + let {getByRole, getByTestId} = render(); + + let dialog = getByRole('dialog'); + let closeBtn = getByTestId('closebtn'); + expect(dialog).toBeVisible(); + await waitForDomChange(); // wait for animation + + triggerPress(closeBtn); + expect(dialog).toBeVisible(); + expect(onOpenChange).toHaveBeenCalledTimes(1); + expect(onOpenChange).toHaveBeenCalledWith(false); + + await waitForDomChange(); // wait for animation + + expect(() => { + getByRole('dialog'); + }).toThrow(); + }); + + it('can be closed by dismiss button in dialog', async function () { + function Test({defaultOpen, onOpenChange}) { + return ( + + + Trigger + contents + + + ); + } + + let onOpenChange = jest.fn(); + let {getByRole, getByLabelText} = render(); + + let dialog = getByRole('dialog'); + expect(dialog).toBeVisible(); + await waitForDomChange(); // wait for animation + + let closeButton = getByLabelText('dismiss'); + triggerPress(closeButton); + expect(dialog).toBeVisible(); + expect(onOpenChange).toHaveBeenCalledTimes(1); + expect(onOpenChange).toHaveBeenCalledWith(false); + + await waitForDomChange(); // wait for animation + + expect(() => { + getByRole('dialog'); + }).toThrow(); + }); + + it('dismissable modals can be closed by clicking outside the dialog', async function () { + function Test({defaultOpen, onOpenChange}) { + return ( + + + Trigger + contents + + + ); + } + + let onOpenChange = jest.fn(); + let {getByRole} = render(); + + let dialog = getByRole('dialog'); + expect(dialog).toBeVisible(); + await waitForDomChange(); // wait for animation + + fireEvent.mouseUp(document.body); + expect(dialog).toBeVisible(); + expect(onOpenChange).toHaveBeenCalledTimes(1); + expect(onOpenChange).toHaveBeenCalledWith(false); + + await waitForDomChange(); // wait for animation + + expect(() => { + getByRole('dialog'); + }).toThrow(); + }); + + it('non dismissable modals cannot be closed by clicking outside the dialog', async function () { + function Test({defaultOpen, onOpenChange}) { + return ( + + + Trigger + contents + + + ); + } + + let onOpenChange = jest.fn(); + let {getByRole} = render(); + + let dialog = getByRole('dialog'); + expect(dialog).toBeVisible(); + await waitForDomChange(); // wait for animation + + fireEvent.mouseUp(document.body); + expect(dialog).toBeVisible(); + expect(onOpenChange).toHaveBeenCalledTimes(0); + }); + + it('non-modals can be closed by clicking outside the dialog regardless of isDismissable', async function () { + function Test({defaultOpen, onOpenChange}) { + return ( + + + Trigger + contents + + + ); + } + + let onOpenChange = jest.fn(); + let {getByRole} = render(); + + let dialog = getByRole('dialog'); + expect(dialog).toBeVisible(); + await waitForDomChange(); // wait for animation + fireEvent.mouseUp(document.body); + expect(dialog).toBeVisible(); + expect(onOpenChange).toHaveBeenCalledTimes(1); + expect(onOpenChange).toHaveBeenCalledWith(false); + + await waitForDomChange(); // wait for animation + + expect(() => { + getByRole('dialog'); + }).toThrow(); + }); }); diff --git a/packages/@react-spectrum/overlays/src/Modal.tsx b/packages/@react-spectrum/overlays/src/Modal.tsx index 7ee9f975ed0..b20c051dda2 100644 --- a/packages/@react-spectrum/overlays/src/Modal.tsx +++ b/packages/@react-spectrum/overlays/src/Modal.tsx @@ -22,7 +22,8 @@ interface ModalProps { children: ReactElement, isOpen?: boolean, onClose?: () => void, - type?: 'fullscreen' | 'fullscreenTakeover' + type?: 'fullscreen' | 'fullscreenTakeover', + isDismissable?: boolean } interface ModalWrapperProps extends ModalProps { @@ -30,14 +31,15 @@ interface ModalWrapperProps extends ModalProps { } export function Modal(props: ModalProps) { - let {children, onClose, type, ...otherProps} = props; + let {children, onClose, type, isDismissable, ...otherProps} = props; return ( + type={type} + isDismissable={isDismissable}> {children} @@ -50,10 +52,11 @@ let typeMap = { }; function ModalWrapper(props: ModalWrapperProps) { - let {children, onClose, isOpen, type} = props; + let {children, onClose, isOpen, type, isDismissable = false} = props; let typeVariant = typeMap[type]; let ref = useRef(null); - let {overlayProps} = useOverlay({ref, onClose, isOpen}); + + let {overlayProps} = useOverlay({ref, onClose, isOpen, isDismissable}); useModal(); let wrapperClassName = classNames( diff --git a/packages/@react-spectrum/overlays/src/Popover.tsx b/packages/@react-spectrum/overlays/src/Popover.tsx index 3c17898c546..e61986b9822 100644 --- a/packages/@react-spectrum/overlays/src/Popover.tsx +++ b/packages/@react-spectrum/overlays/src/Popover.tsx @@ -30,7 +30,7 @@ function Popover(props: PopoverProps, ref: RefObject) { let {style, children, placement = 'bottom', arrowProps, isOpen, onClose, hideArrow, ...otherProps} = props; let backupRef = useRef(); let domRef = ref || backupRef; - let {overlayProps} = useOverlay({ref: domRef, onClose, isOpen}); + let {overlayProps} = useOverlay({ref: domRef, onClose, isOpen, isDismissable: true}); return (
@@ -81,6 +81,20 @@ describe('Modal', function () { ); await waitForDomChange(); // wait for animation fireEvent.mouseUp(document.body); + expect(onClose).toHaveBeenCalledTimes(0); + }); + + it('hides the modal when clicking outside if isDismissible is true', async function () { + let onClose = jest.fn(); + render( + + +
contents
+
+
+ ); + await waitForDomChange(); // wait for animation + fireEvent.mouseUp(document.body); expect(onClose).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/@react-types/dialog/src/index.d.ts b/packages/@react-types/dialog/src/index.d.ts index d6f910c7d35..cf8d230fd1d 100644 --- a/packages/@react-types/dialog/src/index.d.ts +++ b/packages/@react-types/dialog/src/index.d.ts @@ -15,8 +15,10 @@ import {HTMLAttributes, ReactElement, ReactNode, RefObject} from 'react'; import {PositionProps} from '@react-types/overlays'; import {Slots} from '@react-types/layout'; +export type SpectrumDialogClose = (close: () => void) => ReactElement; + export interface SpectrumDialogTriggerProps extends PositionProps { - children: ReactElement[], + children: [ReactElement, SpectrumDialogClose | ReactElement], type?: 'modal' | 'popover' | 'tray' | 'fullscreen' | 'fullscreenTakeover', mobileType?: 'modal' | 'tray' | 'fullscreen' | 'fullscreenTakeover', hideArrow?: boolean, @@ -42,7 +44,6 @@ export interface SpectrumDialogProps extends DOMProps, StyleProps { role?: 'dialog' | 'alertdialog' } - export interface SpectrumAlertDialogProps extends DOMProps, StyleProps { variant?: 'confirmation' | 'information' | 'destructive' | 'error' | 'warning' title: string,