diff --git a/.changeset/stupid-lemons-cough.md b/.changeset/stupid-lemons-cough.md new file mode 100644 index 0000000000..edac732473 --- /dev/null +++ b/.changeset/stupid-lemons-cough.md @@ -0,0 +1,5 @@ +--- +'@sumup/circuit-ui': major +--- + +Removed the exports of the `Modal`, `ModalWrapper`, `ModalHeader`, `ModalFooter`, `ModalContext`, and `ModalConsumer` components. Use the `useModal` hook instead. diff --git a/docs/introduction/getting-started.stories.mdx b/docs/introduction/getting-started.stories.mdx index 848c639feb..23374b329e 100644 --- a/docs/introduction/getting-started.stories.mdx +++ b/docs/introduction/getting-started.stories.mdx @@ -41,18 +41,20 @@ npm install --save @sumup/collector @sumup/design-tokens @sumup/icons react reac Finally, you need to make the theme available to Circuit UI. SumUp's default themes are part of the [@sumup/design-tokens](https://www.npmjs.com/package/@sumup/design-tokens) package. In most cases, they should cover your needs. Refer to the [Theme documentation](Features/Theme) to learn how to use and customize the theme. -At the root of your application, wrap the components in the `ThemeProvider` from Emotion: +At the root of your application, wrap the components in the `ThemeProvider` from Emotion and add the `BaseStyles` component: -```jsx +```tsx +// _app.tsx for Next.js or App.js for CRA import { ThemeProvider } from 'emotion-theming'; import { light } from '@sumup/design-tokens'; -import { Button } from '@sumup/circuit-ui'; - -const App = () => ( - - - -); - -export default App; +import { BaseStyles } from '@sumup/circuit-ui'; + +export default function App() { + return ( + + + {/* Your content here... */} + + ); +} ``` diff --git a/packages/circuit-ui/components/Anchor/Anchor.tsx b/packages/circuit-ui/components/Anchor/Anchor.tsx index c8362e280f..af321bbaba 100644 --- a/packages/circuit-ui/components/Anchor/Anchor.tsx +++ b/packages/circuit-ui/components/Anchor/Anchor.tsx @@ -13,7 +13,14 @@ * limitations under the License. */ -import { forwardRef, HTMLProps, ReactNode, MouseEvent, Ref } from 'react'; +import { + forwardRef, + HTMLProps, + ReactNode, + MouseEvent, + KeyboardEvent, + Ref, +} from 'react'; import { css } from '@emotion/core'; import { Dispatch as TrackingProps } from '@sumup/collector'; import { Theme } from '@sumup/design-tokens'; @@ -22,10 +29,14 @@ import { focusOutline } from '../../styles/style-mixins'; import { ReturnType } from '../../types/return-type'; import { Body, BodyProps } from '../Body/Body'; import { useComponents } from '../ComponentsContext'; -import useClickHandler from '../../hooks/use-click-handler'; +import { useClickHandler } from '../../hooks/useClickHandler'; export interface BaseProps extends BodyProps { children: ReactNode; + /** + * Function that's called when the button is clicked. + */ + onClick?: (event: MouseEvent | KeyboardEvent) => void; /** * Additional data that is dispatched with the tracking event. */ @@ -35,8 +46,8 @@ export interface BaseProps extends BodyProps { */ ref?: Ref; } -type LinkElProps = Omit, 'size'>; -type ButtonElProps = Omit, 'size'>; +type LinkElProps = Omit, 'size' | 'onClick'>; +type ButtonElProps = Omit, 'size' | 'onClick'>; export type AnchorProps = BaseProps & LinkElProps & ButtonElProps; @@ -94,7 +105,7 @@ export const Anchor = forwardRef( /* eslint-disable @typescript-eslint/no-unsafe-assignment */ const Link = components.Link as any; - const handleClick = useClickHandler>( + const handleClick = useClickHandler( props.onClick, tracking, 'anchor', diff --git a/packages/circuit-ui/components/Button/Button.tsx b/packages/circuit-ui/components/Button/Button.tsx index 62e72411a9..d602938b1a 100644 --- a/packages/circuit-ui/components/Button/Button.tsx +++ b/packages/circuit-ui/components/Button/Button.tsx @@ -21,6 +21,7 @@ import { FC, SVGProps, MouseEvent, + KeyboardEvent, } from 'react'; import { css } from '@emotion/core'; import isPropValid from '@emotion/is-prop-valid'; @@ -35,7 +36,7 @@ import { } from '../../styles/style-mixins'; import { ReturnType } from '../../types/return-type'; import { useComponents } from '../ComponentsContext'; -import useClickHandler from '../../hooks/use-click-handler'; +import { useClickHandler } from '../../hooks/useClickHandler'; export interface BaseProps { 'children': ReactNode; @@ -68,6 +69,10 @@ export interface BaseProps { * The HTML button type */ 'type'?: 'button' | 'submit' | 'reset' | undefined; + /** + * Function that's called when the button is clicked. + */ + 'onClick'?: (event: MouseEvent | KeyboardEvent) => void; /** * Additional data that is dispatched with the tracking event. */ @@ -79,8 +84,8 @@ export interface BaseProps { 'data-testid'?: string; } -type LinkElProps = Omit, 'size'>; -type ButtonElProps = Omit, 'size'>; +type LinkElProps = Omit, 'size' | 'onClick'>; +type ButtonElProps = Omit, 'size' | 'onClick'>; export type ButtonProps = BaseProps & LinkElProps & ButtonElProps; @@ -290,7 +295,7 @@ export const Button = forwardRef( /* eslint-disable @typescript-eslint/no-unsafe-assignment */ const Link = components.Link as any; - const handleClick = useClickHandler>( + const handleClick = useClickHandler( props.onClick, tracking, 'button', diff --git a/packages/circuit-ui/components/Carousel/Carousel.js b/packages/circuit-ui/components/Carousel/Carousel.js index 06af689dcc..8670ec8b7f 100644 --- a/packages/circuit-ui/components/Carousel/Carousel.js +++ b/packages/circuit-ui/components/Carousel/Carousel.js @@ -24,7 +24,7 @@ import { childrenPropType, childrenRenderPropType, } from '../../util/shared-prop-types'; -import useComponentSize from '../../hooks/use-component-size'; +import { useComponentSize } from '../../hooks/useComponentSize'; import Container from './components/Container'; import Slides from './components/Slides'; diff --git a/packages/circuit-ui/components/Checkbox/Checkbox.tsx b/packages/circuit-ui/components/Checkbox/Checkbox.tsx index 1d5c4e3926..0a0375a0fd 100644 --- a/packages/circuit-ui/components/Checkbox/Checkbox.tsx +++ b/packages/circuit-ui/components/Checkbox/Checkbox.tsx @@ -25,7 +25,7 @@ import { focusOutline, } from '../../styles/style-mixins'; import { uniqueId } from '../../util/id'; -import useClickHandler from '../../hooks/use-click-handler'; +import { useClickHandler } from '../../hooks/useClickHandler'; import Tooltip from '../Tooltip'; export interface CheckboxProps extends HTMLProps { diff --git a/packages/circuit-ui/components/Modal/Modal.docs.mdx b/packages/circuit-ui/components/Modal/Modal.docs.mdx index a04845d049..f295cef0e4 100644 --- a/packages/circuit-ui/components/Modal/Modal.docs.mdx +++ b/packages/circuit-ui/components/Modal/Modal.docs.mdx @@ -1,122 +1,66 @@ import { Status, Props, Story } from '../../../../.storybook/components'; -import { ModalWrapper, ModalHeader, ModalFooter } from '.'; # Modal -Modals are floating cards which overlay the primary UI. All content in a single modal should be related to completing one single task. Modals are heavy UI elements which obscure the primary user interface — avoid them where possible. +The modal component displays self-contained tasks in a focused window that overlays the page content. - - - + ## When to use it -Use it when you want the user to focus on a single and perhaps more complex task. +Generally, use the modal component sparingly. Consider displaying more complex tasks and large amounts of information on a separate page instead. -## Usage guidelines +## Variants -#### General guidelines + -- **Do** use modals sparingly. -- **Do** use modals when you want to isolate an action from the primary UI. -- **Do not** draw a modal over another modal. -- **Do not** fill a modal with content which has multiple end results. -- **Do not** present a modal without a user prompting a modal (e.g. as a popup). +### Contextual -#### Header guidelines +Use this variant when the modal content requires the context of the page underneath to be understood. On small viewports, the modal component opens up from the bottom as a bottom sheet overlay on top of the page content, dimming the uncovered area while giving a visual context of the page underneath. The height of the bottom sheet can be manually adjusted depending on the use case and the amount of content needed to be displayed. -- **Do** use concise yet descriptive headings that label the function of the specific modal. -- **Do not** exclude headings from modals. +### Immersive -#### Content guidelines - -- **Do** align text content to the left. -- **Do not** have more than two columns of content. - -#### Footer guidelines - -The modal footer contains CTA's which carry out an action on the entire modal. - -- **Do** align modal CTA's to the right side of the footer. -- **Do not** have more than one "Primary - Major" CTA. +Use this variant to focus the user's attention on the modal content. On small viewports, the modal component opens up from the bottom as a fullscreen overlay on top of the page content and covers it entirely in favor of an immersive experience. ## Usage in code -There are a number of ways to use a modal in code. In some codebases, you -may opt to create a helper higher-order component that complements the -`ModalConsumer`. - -### Using the ModalProvider +First, wrap your application in the `ModalProvider` which keeps track of the open modals, prevents scrolling of the page when a modal is open, and ensures the accessibility of the modal. -The benefit of using the ModalProvider is that it can be declared once at the application root, and you do not need to manage the open/closed state of the modal yourself. - -```js -import { - useModal, - ModalProvider, - ModalWrapper, - ModalHeader, - Button, -} from '@sumup/circuit-ui'; - -const SayHello = ({ name }) => { - const { setModal } = useModal(); - const showModal = () => { - setModal({ - children: ({ onClose }) => ( - - - Hello {name} - - ), - }); - }; - return ; -}; +```tsx +// _app.tsx for Next.js or App.js for CRA +import { ThemeProvider } from 'emotion-theming'; +import { light } from '@sumup/design-tokens'; +import { ModalProvider, BaseStyles } from '@sumup/circuit-ui'; -const Page = () => { +export default function App() { return ( - - - + + + {/* Your content here... */} + ); -}; +} ``` -- `ModalWrapper` This is the wrapper for the body of a modal. -- `ModalHeader` This contains the title and the `X` close button. -- `ModalFooter` This component aligns its content. - -### Embedding the modal in code +Then, use the `useModal` hook to open a modal from a component: -If you prefer to embed the code declaratively inside the component, you can do it as such: +```tsx +import { useModal, Button, Body } from '@sumup/circuit-ui'; -```js -import { Modal, ModalWrapper, ModalHeader, Button } from '@sumup/circuit-ui'; - -const Page = () => { - const [isModalOpen, setModalOpen] = useState(false); +export function SayHello({ name }) { + const { setModal } = useModal(); - const toggleModal = () => { - setModalOpen((prev) => !prev); + const handleClick = () => { + setModal({ + children: Hello {name}, + variant: 'immersive', + closeButtonLabel: 'Close modal', + }); }; - return ( - - - - - {({ onClose }) => ( - - - The modal is open! - - )} - - - ); -}; + return ; +} ``` diff --git a/packages/circuit-ui/components/Modal/Modal.embed.stories.tsx b/packages/circuit-ui/components/Modal/Modal.embed.stories.tsx deleted file mode 100644 index b9ec1177ea..0000000000 --- a/packages/circuit-ui/components/Modal/Modal.embed.stories.tsx +++ /dev/null @@ -1,118 +0,0 @@ -/** - * Copyright 2019, SumUp Ltd. - * Licensed 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 CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import styled from '@emotion/styled'; -import { css } from '@emotion/core'; -import { action } from '@storybook/addon-actions'; - -import Button from '../Button'; -import ButtonGroup from '../ButtonGroup'; -import Body from '../Body'; - -import { ModalHeader, ModalFooter } from './components'; -import { - ModalWrapper, - ModalWrapperProps, -} from './components/ModalWrapper/ModalWrapper'; - -export default { - title: 'Components/Modal/Embedded', - component: ModalWrapper, -}; - -export const Base = (args: ModalWrapperProps) => ( - Hello World! -); - -export const WithTitle = (args: ModalWrapperProps) => ( - - - Hello world! - -); - -export const WithoutCloseButton = (args: ModalWrapperProps) => ( - - Some text in the modal body. - -); - -export const WithTitleAndCloseButton = (args: ModalWrapperProps) => ( - - - Some text in the modal body. - -); - -export const WithFooter = (args: ModalWrapperProps) => ( - - - Some text in the modal body. - - - - - - - -); - -export const WithCustomStyles = (args: ModalWrapperProps) => { - const Container = styled('div')` - display: flex; - justify-content: stretch; - align-items: stretch; - flex-wrap: nowrap; - height: 100%; - background: #fff; - `; - - const LeftColumn = styled('div')` - display: flex; - align-items: center; - width: 50%; - justify-content: center; - padding: 24px 18px; - `; - - const RightColumn = styled('div')` - height: 100%; - width: 50%; - background: no-repeat center / cover - url('https://source.unsplash.com/9K9ipjhDdks/900x1600'); - `; - - return ( - - - - A nice custom modal for special cases. - - - - - ); -}; diff --git a/packages/circuit-ui/components/Modal/Modal.spec.tsx b/packages/circuit-ui/components/Modal/Modal.spec.tsx index 486efc8173..73a58ee0df 100644 --- a/packages/circuit-ui/components/Modal/Modal.spec.tsx +++ b/packages/circuit-ui/components/Modal/Modal.spec.tsx @@ -13,97 +13,53 @@ * limitations under the License. */ -import { render, act, userEvent, waitFor } from '../../util/test-utils'; -import Button from '../Button'; +import { render, act, userEvent, axe } from '../../util/test-utils'; -import { ModalProps } from './Modal'; -import { ModalConsumer, ModalProvider } from './ModalContext'; -import * as MockedModal from './Modal'; +import { Modal, ModalProps } from './Modal'; describe('Modal', () => { - beforeEach(() => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - (MockedModal as any).TRANSITION_DURATION = 0; - }); - - // eslint-disable-next-line react/prop-types - const PageWithModal = ({ modal }: { modal: ModalProps }) => ( -
- - - {({ setModal }) => ( - - )} - - -
- ); - const defaultModal: ModalProps = { + variant: 'immersive', + isOpen: true, + closeButtonLabel: 'Close modal', + onClose: jest.fn(), // eslint-disable-next-line react/prop-types, react/display-name - children: ({ onClose }) => ( -
-
Hello World!
-
- ), - // Disables the need for a wrapper. I couldn't get the Modal to work - // with the wrapper enabled. Here's an issue describing that it - // should work: - // https://github.com/reactjs/react-modal/issues/563 - // Here are the docs for setting the app element: + children: () =>

Hello world!

, + // Silences the warning about the missing app element. + // In user land, the modal is always rendered by the ModalProvider, + // which takes care of setting the app element. // http://reactcommunity.org/react-modal/accessibility/#app-element ariaHideApp: false, - onClose: jest.fn(), - }; - - const openModal = (modal: ModalProps) => { - const wrapper = render(); - - act(() => { - userEvent.click(wrapper.getByTestId('button-open')); - }); - - return wrapper; }; - beforeEach(() => { - jest.resetAllMocks(); + it('should match the snapshot', () => { + const { baseElement } = render(); + expect(baseElement).toMatchSnapshot(); }); - it('should open', () => { - const { getByTestId } = openModal(defaultModal); - expect(getByTestId('card')).toBeVisible(); + it('should render the modal', () => { + const { getByRole } = render(); + expect(getByRole('dialog')).toBeVisible(); }); - describe('closing the modal', () => { - it('should be closeable by pressing a close button', async () => { - const { getByTestId, queryByTestId } = openModal(defaultModal); - - act(() => { - userEvent.click(getByTestId('button-close')); - }); + it('should call the onClose callback', () => { + const { getByRole } = render(); - await waitFor(() => { - expect(defaultModal.onClose).toHaveBeenCalled(); - expect(queryByTestId('card')).toBeNull(); - }); + act(() => { + userEvent.click(getByRole('button')); }); + + expect(defaultModal.onClose).toHaveBeenCalled(); }); it('should render the children render prop', () => { - const { getByTestId } = openModal(defaultModal); - expect(getByTestId('card')).toHaveTextContent('Hello World!'); + const { getByTestId } = render(); + expect(getByTestId('children')).toHaveTextContent('Hello world!'); + }); + + it('should meet accessibility guidelines', async () => { + const { container } = render(); + const actual = await axe(container); + expect(actual).toHaveNoViolations(); }); }); diff --git a/packages/circuit-ui/components/Modal/Modal.stories.tsx b/packages/circuit-ui/components/Modal/Modal.stories.tsx index 80a35b888d..0e135b49ee 100644 --- a/packages/circuit-ui/components/Modal/Modal.stories.tsx +++ b/packages/circuit-ui/components/Modal/Modal.stories.tsx @@ -13,170 +13,164 @@ * limitations under the License. */ -import { MouseEvent, KeyboardEvent } from 'react'; -import styled from '@emotion/styled'; +/* eslint-disable react/display-name */ +import { Fragment } from 'react'; import { css } from '@emotion/core'; -import { action } from '@storybook/addon-actions'; +import { Theme } from '@sumup/design-tokens'; +import { Stack } from '../../../../.storybook/components'; import Button from '../Button'; -import ButtonGroup from '../ButtonGroup'; import Body from '../Body'; +import Image from '../Image'; +import { ModalProvider } from '../ModalContext'; +import { spacing } from '../../styles/style-mixins'; import docs from './Modal.docs.mdx'; -import { ModalWrapper, ModalHeader, ModalFooter } from './components'; -import { ModalConsumer, ModalProvider } from './ModalContext'; import { Modal, ModalProps } from './Modal'; +import { useModal } from './useModal'; export default { title: 'Components/Modal', component: Modal, + subcomponents: { ModalProvider }, parameters: { docs: { page: docs }, }, }; -/* eslint-disable react/display-name, react/prop-types */ - -const PageWithModal = (modal: ModalProps) => ( - - - {({ setModal }) => ( - - )} - - -); - -const defaultModal = { - children: () => Hello World!, - onClose: () => {}, +export const Base = (modal: ModalProps): JSX.Element => { + const ComponentWithModal = () => { + const { setModal } = useModal(); + + return ( + + ); + }; + return ( + + + + ); +}; + +Base.args = { + children: 'Hello World!', + variant: 'contextual', + closeButtonLabel: 'Close modal', +}; + +export const Variants = (modal: ModalProps): JSX.Element => { + const ComponentWithModal = ({ variant }: Pick) => { + const { setModal } = useModal(); + + return ( + + ); + }; + return ( + + + + + + + ); }; -export const Base = (args: ModalProps) => ( - -); - -export const WithHeader = (args: ModalProps) => ( - - {() => ( - - - Some text in the modal body. - - )} - -); - -export const WithoutCloseButton = (args: ModalProps) => ( - - {() => ( - - Some text in the modal body. - - )} - -); - -export const WithTitleAndCloseButton = (args: ModalProps) => ( - - {({ onClose }) => ( - - - Some text in the modal body. - - )} - -); - -export const WithFooter = (args: ModalProps) => ( - - {({ onClose }) => ( - - - Some text in the modal body. - - - - - - - - )} - -); - -export const WithCustomStyles = (args: ModalProps) => { - const Container = styled('div')` - display: flex; - justify-content: stretch; - align-items: stretch; - flex-wrap: nowrap; - height: 100%; - background: #fff; - `; - - const LeftColumn = styled('div')` - display: flex; - align-items: center; - width: 50%; - justify-content: center; - padding: 24px 18px; - `; - - const RightColumn = styled('div')` - height: 100%; - width: 50%; - background: no-repeat center / cover - url('https://source.unsplash.com/S4W2AU0t3lw/900x1600'); - `; +Variants.args = { + children: 'Hello World!', + closeButtonLabel: 'Close modal', +}; + +export const PreventClose = (modal: ModalProps): JSX.Element => { + const ComponentWithModal = () => { + const { setModal } = useModal(); + return ( + + ); + }; return ( - - {() => ( -
- - - A nice custom modal for special cases. - - - -
- )} -
+ + + ); }; + +PreventClose.args = { + children: ({ onClose }: { onClose: ModalProps['onClose'] }) => ( + + + Users have to complete the action inside the modal to close it. The + close button is hidden and clicking outside the modal or pressing the + escape key does not close the modal either. + + + + ), + variant: 'immersive', + preventClose: true, +}; + +export const InitiallyOpen = (modal: ModalProps): JSX.Element => { + const initialModal = { id: 'initial', component: Modal, ...modal }; + return ( + +
+ + ); +}; + +InitiallyOpen.args = { + children: 'Hello World!', + variant: 'contextual', + closeButtonLabel: 'Close modal', +}; + +export const CustomStyles = (modal: ModalProps): JSX.Element => { + const ComponentWithModal = () => { + const { setModal } = useModal(); + return ( + + ); + }; + + return ( + + + + ); +}; + +CustomStyles.args = { + css: (theme: Theme) => css` + overflow: hidden; + + ${theme.mq.untilKilo} { + padding: 0; + } + ${theme.mq.kilo} { + padding: 0; + } + `, + children: ( + + + + Custom styles can be applied using the css prop. + + + ), + variant: 'contextual', + closeButtonLabel: 'Close modal', +}; diff --git a/packages/circuit-ui/components/Modal/Modal.tsx b/packages/circuit-ui/components/Modal/Modal.tsx index c2847b410c..3413c1dc00 100644 --- a/packages/circuit-ui/components/Modal/Modal.tsx +++ b/packages/circuit-ui/components/Modal/Modal.tsx @@ -13,188 +13,218 @@ * limitations under the License. */ -import { FC, MouseEvent, KeyboardEvent, ReactNode } from 'react'; -import ReactModal, { Props } from 'react-modal'; -import { ClassNames } from '@emotion/core'; -import { useTheme } from 'emotion-theming'; +import { ReactNode } from 'react'; +import { css, ClassNames } from '@emotion/core'; +import ReactModal from 'react-modal'; import { Theme } from '@sumup/design-tokens'; -import { Dispatch as TrackingProps } from '@sumup/collector'; -import noScroll from 'no-scroll'; -import IS_IOS from '../../util/ios'; import { isFunction } from '../../util/type-check'; -import useClickHandler from '../../hooks/use-click-handler'; - -type OnClose = (event?: MouseEvent | KeyboardEvent) => void; - -export interface ModalProps extends Partial { - children: ReactNode | (({ onClose }: { onClose?: OnClose }) => ReactNode); - /** - * Determines if the modal is visible or not. - */ - isOpen?: boolean; - /** - * Function to close the modal. Passed down to the children - * render prop. - */ - onClose?: OnClose; - /** - * React Modal's accessibility string. - */ - contentLabel?: string; - /** - * The element that should be used as root for the - * React portal used to display the modal. See - * http://reactcommunity.org/react-modal/accessibility/#app-element - */ - appElement?: string | HTMLElement; - /** - * Additional data that is dispatched with the tracking event. - */ - tracking?: TrackingProps; -} - -export const TRANSITION_DURATION = 200; -export const DEFAULT_APP_ELEMENT = '#root'; - -const TOP_MARGIN = '10vh'; -const TRANSFORM_Y_FLOATING = '10vh'; -const FLOATING_TRANSITION = `${TRANSITION_DURATION}ms ease-in-out`; -// eslint-disable-next-line max-len -const FIXED_TRANSITION = `${TRANSITION_DURATION}ms cubic-bezier(0, 0.37, 0.64, 1)`; +import { useClickHandler } from '../../hooks/useClickHandler'; +import { ModalComponent, BaseModalProps } from '../ModalContext/ModalContext'; +import CloseButton from '../CloseButton'; + +const TRANSITION_DURATION_MOBILE = 120; +const TRANSITION_DURATION_DESKTOP = 240; +const TRANSITION_DURATION = Math.max( + TRANSITION_DURATION_MOBILE, + TRANSITION_DURATION_DESKTOP, +); + +const closeButtonStyles = (theme: Theme) => css` + position: absolute; + top: ${theme.spacings.byte}; + right: ${theme.spacings.byte}; + + ${theme.mq.kilo} { + top: ${theme.spacings.mega}; + right: ${theme.spacings.mega}; + } +`; + +type PreventCloseProps = + | { + /** + * Text label for the close button for screen readers. + * Important for accessibility. + */ + closeButtonLabel?: never; + /** + * Prevent users from closing the modal by clicking/tapping the overlay or + * pressing the escape key. Default `false`. + */ + preventClose: boolean; + } + | { + closeButtonLabel: string; + preventClose?: never; + }; + +export type ModalProps = BaseModalProps & + PreventCloseProps & { + /** + * The modal content. Use a render function when you need access to the + * `onClose` function. + */ + children: + | ReactNode + | (({ onClose }: Pick) => ReactNode); + /** + * Use the `contextual` variant when the modal content requires the context + * of the page underneath to be understood, otherwise, use the `immersive` + * variant to focus the user's attention. + */ + variant: 'contextual' | 'immersive'; + /** + * Custom styles for the modal wrapper element. + */ + className?: string; + }; /** - * Circuit UI's wrapper component for ReactModal. Uses the Card component - * to wrap content passed as the children prop. Don't forget to set - * the aria prop when using this. - * http://reactcommunity.org/react-modal/accessibility/#aria + * The modal component displays self-contained tasks in a focused window that + * overlays the page content. + * Built on top of [`react-modal`](https://reactcommunity.org/react-modal/). */ -export const Modal: FC = ({ +export const Modal: ModalComponent = ({ children, onClose, - contentLabel = 'Modal', - appElement = DEFAULT_APP_ELEMENT, - isOpen = true, + variant, + preventClose = false, + closeButtonLabel, tracking = {}, + className, ...props }) => { - const theme: Theme = useTheme(); - const handleClose = - useClickHandler(onClose, tracking, 'modal-close') || onClose; - ReactModal.setAppElement(appElement); + const handleClose = useClickHandler(onClose, tracking, 'modal-close'); return ( - - {({ css }) => { + key={variant}> + {({ css: cssString, cx, theme }) => { // React Modal styles // https://reactcommunity.org/react-modal/styles/classes/ - const className = { - base: css` - label: modal; - outline: none; - - ${theme.mq.untilKilo} { - bottom: 0; - max-height: 80vh; - -webkit-overflow-scrolling: touch; - overflow-y: auto; + const styles = { + base: cx( + cssString` position: fixed; - transform: translateY(100%); - transition: transform ${FIXED_TRANSITION}; - width: 100%; - width: 100vw; - } - - ${theme.mq.kilo} { - transition: transform ${FLOATING_TRANSITION}, - opacity ${FLOATING_TRANSITION}; - margin: ${TOP_MARGIN} auto auto; - max-height: 90vh; - max-width: 90%; - min-width: 450px; - opacity: 0; - position: relative; - transform: translateY(${TRANSFORM_Y_FLOATING}); - } - - ${theme.mq.mega} { - max-width: 720px; - } - - ${theme.mq.giga} { - max-width: 800px; - } - `, - afterOpen: css` + outline: none; + background-color: ${theme.colors.white}; + + ${theme.mq.untilKilo} { + right: 0; + bottom: 0; + left: 0; + -webkit-overflow-scrolling: touch; + overflow-y: auto; + width: 100vw; + transform: translateY(100%); + transition: transform ${TRANSITION_DURATION_MOBILE}ms ease-in-out; + padding: ${theme.spacings.mega}; + } + + ${theme.mq.kilo} { + top: 50%; + left: 50%; + padding: ${theme.spacings.giga}; + transform: translate(-50%, -50%); + min-height: 320px; + max-height: 90vh; + min-width: 480px; + max-width: 90vw; + opacity: 0; + transition: opacity ${TRANSITION_DURATION_DESKTOP}ms ease-in-out; + border-radius: ${theme.borderRadius.mega}; + } + `, + variant === 'immersive' && + cssString` + ${theme.mq.untilKilo} { + height: 100vh; + } + `, + variant === 'contextual' && + cssString` + ${theme.mq.untilKilo} { + max-height: calc(100vh - ${theme.spacings.mega}); + border-top-left-radius: ${theme.borderRadius.mega}; + border-top-right-radius: ${theme.borderRadius.mega}; + } + `, + className, + ), + // The !important below is necessary because of some weird + // style specificity issues in Emotion. + afterOpen: cssString` label: modal--after-open; + ${theme.mq.untilKilo} { - transform: translateY(0); + transform: translateY(0) !important; } ${theme.mq.kilo} { - opacity: 1; - transform: translateY(0); + opacity: 1 !important; } `, - beforeClose: css` + beforeClose: cssString` label: modal--before-close; + ${theme.mq.untilKilo} { transform: translateY(100%); } ${theme.mq.kilo} { opacity: 0; - transform: translateY(${TRANSFORM_Y_FLOATING}); } `, }; - const overlayClassName = { - base: css` - label: modal__overlay; - background: ${theme.colors.overlay}; - bottom: 0; - left: 0; - opacity: 0; + const overlayStyles = { + base: cssString` position: fixed; - right: 0; top: 0; - transition: opacity 200ms ease-in-out; + left: 0; + bottom: 0; + right: 0; + opacity: 0; + transition: opacity ${TRANSITION_DURATION_MOBILE}ms ease-in-out; + background: ${theme.colors.overlay}; z-index: ${theme.zIndex.modal}; ${theme.mq.kilo} { -webkit-overflow-scrolling: touch; overflow-y: auto; + transition: opacity ${TRANSITION_DURATION_DESKTOP}ms ease-in-out; } `, - afterOpen: css` - label: modal__overlay--after-open; + afterOpen: cssString` opacity: 1; `, - beforeClose: css` - label: modal__overlay--before-close; + beforeClose: cssString` opacity: 0; `, }; const reactModalProps = { - isOpen, - className, - overlayClassName, - htmlOpenClassName: 'ReactModal__Html--open', - contentLabel, - onAfterOpen: () => IS_IOS && noScroll.on(), - onAfterClose: () => IS_IOS && noScroll.off(), + className: styles, + overlayClassName: overlayStyles, onRequestClose: handleClose, closeTimeoutMS: TRANSITION_DURATION, + shouldCloseOnOverlayClick: !preventClose, + shouldCloseOnEsc: !preventClose, ...props, }; + return ( + {!preventClose && closeButtonLabel && ( + + )} + {isFunction(children) - ? children({ - onClose: handleClose, - }) + ? children({ onClose: handleClose }) : children} ); @@ -202,3 +232,5 @@ export const Modal: FC = ({ ); }; + +Modal.TIMEOUT = TRANSITION_DURATION; diff --git a/packages/circuit-ui/components/Modal/ModalContext.tsx b/packages/circuit-ui/components/Modal/ModalContext.tsx deleted file mode 100644 index 725292d632..0000000000 --- a/packages/circuit-ui/components/Modal/ModalContext.tsx +++ /dev/null @@ -1,118 +0,0 @@ -/** - * Copyright 2019, SumUp Ltd. - * Licensed 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 CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { - createContext, - FC, - useState, - useContext, - MouseEvent, - KeyboardEvent, - useCallback, -} from 'react'; -import { Global, css } from '@emotion/core'; - -import { Modal, ModalProps } from './Modal'; - -export type ModalContextValue = { - setModal: (modal: ModalProps) => void; - removeModal: () => void; - isModalOpen: boolean; - /** - * @deprecated - * - * If you need access to the `onClose` method or `isOpen` state of the modal, - * use the `removeModal` and `isOpen` context properties instead. - */ - getModal: () => ModalProps | null; -}; - -export const ModalContext = createContext({ - setModal: () => {}, - removeModal: () => {}, - isModalOpen: false, - getModal: () => null, -}); - -export const ModalConsumer = ModalContext.Consumer; - -export const useModal = (): ModalContextValue => useContext(ModalContext); - -export const ModalProvider: FC> = (props) => { - const [isOpen, setOpen] = useState(false); - const [modal, setModal] = useState(null); - - const closeModal = (): void => { - window.onpopstate = null; - setOpen(false); - }; - - const openModal = useCallback((newModal: ModalProps): void => { - window.onpopstate = closeModal; - setModal(newModal); - setOpen(true); - }, []); - - const { onClose, children, ...modalProps } = modal || {}; - - const handleClose = useCallback( - (event?: MouseEvent | KeyboardEvent): void => { - if (onClose) { - onClose(event); - } - closeModal(); - }, - [onClose], - ); - - const getModal = useCallback(() => modal, [modal]); - - return ( - - {props.children} - - {modal && ( - - {children} - - )} - - {isOpen && ( - - )} - - ); -}; diff --git a/packages/circuit-ui/components/Modal/__snapshots__/Modal.spec.tsx.snap b/packages/circuit-ui/components/Modal/__snapshots__/Modal.spec.tsx.snap new file mode 100644 index 0000000000..154d426d5b --- /dev/null +++ b/packages/circuit-ui/components/Modal/__snapshots__/Modal.spec.tsx.snap @@ -0,0 +1,239 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Modal should match the snapshot 1`] = ` +.circuit-2 { + position: fixed; + outline: none; + background-color: #FFF; +} + +@media (max-width:479px) { + .circuit-2 { + right: 0; + bottom: 0; + left: 0; + -webkit-overflow-scrolling: touch; + overflow-y: auto; + width: 100vw; + -webkit-transform: translateY(100%); + -ms-transform: translateY(100%); + transform: translateY(100%); + -webkit-transition: -webkit-transform 120ms ease-in-out; + -webkit-transition: transform 120ms ease-in-out; + transition: transform 120ms ease-in-out; + padding: 16px; + } +} + +@media (min-width:480px) { + .circuit-2 { + top: 50%; + left: 50%; + padding: 24px; + -webkit-transform: translate(-50%,-50%); + -ms-transform: translate(-50%,-50%); + transform: translate(-50%,-50%); + min-height: 320px; + max-height: 90vh; + min-width: 480px; + max-width: 90vw; + opacity: 0; + -webkit-transition: opacity 240ms ease-in-out; + transition: opacity 240ms ease-in-out; + border-radius: 16px; + } +} + +@media (max-width:479px) { + .circuit-2 { + height: 100vh; + } +} + +@media (max-width:479px) { + .circuit-3 { + -webkit-transform: translateY(0) !important; + -ms-transform: translateY(0) !important; + transform: translateY(0) !important; + } +} + +@media (min-width:480px) { + .circuit-3 { + opacity: 1 !important; + } +} + +.circuit-4 { + position: fixed; + top: 0; + left: 0; + bottom: 0; + right: 0; + opacity: 0; + -webkit-transition: opacity 120ms ease-in-out; + transition: opacity 120ms ease-in-out; + background: rgba(0,0,0,0.4); + z-index: 1000; +} + +@media (min-width:480px) { + .circuit-4 { + -webkit-overflow-scrolling: touch; + overflow-y: auto; + -webkit-transition: opacity 240ms ease-in-out; + transition: opacity 240ms ease-in-out; + } +} + +.circuit-5 { + opacity: 1; +} + +.circuit-1 { + font-size: 16px; + line-height: 24px; + display: -webkit-inline-box; + display: -webkit-inline-flex; + display: -ms-inline-flexbox; + display: inline-flex; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + -webkit-box-pack: center; + -webkit-justify-content: center; + -ms-flex-pack: center; + justify-content: center; + width: auto; + height: auto; + margin: 0; + cursor: pointer; + text-align: center; + -webkit-text-decoration: none; + text-decoration: none; + font-weight: 700; + border-width: 1px; + border-style: solid; + border-radius: 999999px; + -webkit-transition: opacity 120ms ease-in-out,color 120ms ease-in-out,background-color 120ms ease-in-out,border-color 120ms ease-in-out; + transition: opacity 120ms ease-in-out,color 120ms ease-in-out,background-color 120ms ease-in-out,border-color 120ms ease-in-out; + background-color: #FFF; + border-color: #999; + color: #000; + padding: calc(12px - 1px) calc(24px - 1px); + padding: calc(16px - 1px); + border: 0; + position: absolute; + top: 8px; + right: 8px; +} + +.circuit-1:focus { + outline: 0; + box-shadow: 0 0 0 4px #AFD0FE; +} + +.circuit-1:focus::-moz-focus-inner { + border: 0; +} + +.circuit-1:disabled, +.circuit-1[disabled] { + opacity: 0.5; + pointer-events: none; + box-shadow: none; +} + +.circuit-1:hover { + background-color: #F5F5F5; + border-color: #666; +} + +.circuit-1:active { + background-color: #E6E6E6; + border-color: #333; +} + +@media (min-width:480px) { + .circuit-1 { + top: 16px; + right: 16px; + } +} + +.circuit-0 { + border: 0; + -webkit-clip: rect(0 0 0 0); + clip: rect(0 0 0 0); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + white-space: nowrap; + width: 1px; +} + + +
+
+
+
+
+ +
+
+ +`; diff --git a/packages/circuit-ui/components/Modal/components/ModalFooter/ModalFooter.spec.tsx b/packages/circuit-ui/components/Modal/components/ModalFooter/ModalFooter.spec.tsx deleted file mode 100644 index 96192aaf47..0000000000 --- a/packages/circuit-ui/components/Modal/components/ModalFooter/ModalFooter.spec.tsx +++ /dev/null @@ -1,37 +0,0 @@ -/** - * Copyright 2019, SumUp Ltd. - * Licensed 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 CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { create, renderToHtml, axe } from '../../../../util/test-utils'; - -import { ModalFooter } from './ModalFooter'; - -describe('ModalFooter', () => { - /** - * Style tests. - */ - it('should render with default styles', () => { - const actual = create(); - expect(actual).toMatchSnapshot(); - }); - - /** - * Accessibility tests. - */ - it('should meet accessibility guidelines', async () => { - const wrapper = renderToHtml(); - const actual = await axe(wrapper); - expect(actual).toHaveNoViolations(); - }); -}); diff --git a/packages/circuit-ui/components/Modal/components/ModalFooter/ModalFooter.tsx b/packages/circuit-ui/components/Modal/components/ModalFooter/ModalFooter.tsx deleted file mode 100644 index 70f883a3b4..0000000000 --- a/packages/circuit-ui/components/Modal/components/ModalFooter/ModalFooter.tsx +++ /dev/null @@ -1,46 +0,0 @@ -/** - * Copyright 2019, SumUp Ltd. - * Licensed 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 CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { css } from '@emotion/core'; - -import styled, { StyleProps } from '../../../../styles/styled'; -import { CardFooter } from '../../../Card'; - -const footerStyles = ({ theme }: StyleProps) => css` - ${theme.mq.untilKilo} { - position: sticky; - bottom: 0; - margin: 0 -${theme.spacings.mega}; - padding: ${theme.spacings.mega}; - width: calc(100% + 2 * ${theme.spacings.mega}); - background: ${theme.colors.white}; - - &::before { - content: ''; - display: block; - position: absolute; - top: -${theme.spacings.giga}; - right: 0; - width: 100%; - height: ${theme.spacings.giga}; - background: linear-gradient( - rgba(256, 256, 256, 0), - ${theme.colors.white} - ); - } - } -`; - -export const ModalFooter = styled(CardFooter)(footerStyles); diff --git a/packages/circuit-ui/components/Modal/components/ModalFooter/__snapshots__/ModalFooter.spec.tsx.snap b/packages/circuit-ui/components/Modal/components/ModalFooter/__snapshots__/ModalFooter.spec.tsx.snap deleted file mode 100644 index d0486a0345..0000000000 --- a/packages/circuit-ui/components/Modal/components/ModalFooter/__snapshots__/ModalFooter.spec.tsx.snap +++ /dev/null @@ -1,59 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`ModalFooter should render with default styles 1`] = ` -.circuit-0 { - display: block; - width: 100%; - margin-top: 24px; -} - -@media (min-width:480px) { - .circuit-0 { - -webkit-align-items: center; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - margin-top: 16px; - } -} - -@media (min-width:480px) { - .circuit-0 { - -webkit-box-pack: end; - -webkit-justify-content: flex-end; - -ms-flex-pack: end; - justify-content: flex-end; - } -} - -@media (max-width:479px) { - .circuit-0 { - position: -webkit-sticky; - position: sticky; - bottom: 0; - margin: 0 -16px; - padding: 16px; - width: calc(100% + 2 * 16px); - background: #FFF; - } - - .circuit-0::before { - content: ''; - display: block; - position: absolute; - top: -24px; - right: 0; - width: 100%; - height: 24px; - background: linear-gradient( rgba(256,256,256,0),#FFF ); - } -} - -