From d517167d5b40110f81ea48f5e11175825110a8a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Connor=20B=C3=A4r?= Date: Wed, 19 May 2021 22:56:57 +0200 Subject: [PATCH 01/16] Remove exports of internal modal components --- packages/circuit-ui/components/Modal/index.ts | 27 ++----------------- packages/circuit-ui/index.ts | 19 ++----------- packages/circuit-ui/tsconfig.json | 1 + 3 files changed, 5 insertions(+), 42 deletions(-) diff --git a/packages/circuit-ui/components/Modal/index.ts b/packages/circuit-ui/components/Modal/index.ts index ed4c60a3bb..26b4e918b8 100644 --- a/packages/circuit-ui/components/Modal/index.ts +++ b/packages/circuit-ui/components/Modal/index.ts @@ -13,30 +13,7 @@ * limitations under the License. */ -import { - ModalContext, - ModalProvider, - ModalConsumer, - useModal, -} from './ModalContext'; -import { Modal, DEFAULT_APP_ELEMENT } from './Modal'; -import { ModalWrapper, ModalHeader, ModalFooter } from './components'; - -export { - DEFAULT_APP_ELEMENT, - useModal, - ModalContext, - ModalConsumer, - ModalProvider, - ModalWrapper, - ModalHeader, - ModalFooter, -}; +export { ModalProvider, useModal } from './ModalContext'; +export { Modal } from './Modal'; export type { ModalProps } from './Modal'; - -export type { ModalHeaderProps, ModalWrapperProps } from './components'; - -export type { ModalContextValue } from './ModalContext'; - -export default Modal; diff --git a/packages/circuit-ui/index.ts b/packages/circuit-ui/index.ts index bedb4e3065..2b8c2d816c 100644 --- a/packages/circuit-ui/index.ts +++ b/packages/circuit-ui/index.ts @@ -121,23 +121,8 @@ export type { TagProps } from './components/Tag'; export { default as Popover } from './components/Popover'; export { default as Tooltip } from './components/Tooltip'; export { default as BaseStyles } from './components/BaseStyles'; -export { - default as Modal, - DEFAULT_APP_ELEMENT, - useModal, - ModalContext, - ModalConsumer, - ModalProvider, - ModalWrapper, - ModalHeader, - ModalFooter, -} from './components/Modal'; -export type { - ModalProps, - ModalHeaderProps, - ModalWrapperProps, - ModalContextValue, -} from './components/Modal'; +export { ModalProvider, useModal } from './components/Modal'; +export type { ModalProps } from './components/Modal'; export { default as Table } from './components/Table'; export type { diff --git a/packages/circuit-ui/tsconfig.json b/packages/circuit-ui/tsconfig.json index d69a910e4d..027b555b6a 100644 --- a/packages/circuit-ui/tsconfig.json +++ b/packages/circuit-ui/tsconfig.json @@ -38,6 +38,7 @@ ], "exclude": [ "node_modules", + "dist", "**/*/__tests__/*", "**/*/__testfixtures__/*", "**/*.spec.*", From 6beaba9e7939dbc5d820fd60feafa577f9fab26e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Connor=20B=C3=A4r?= Date: Thu, 20 May 2021 09:40:31 +0200 Subject: [PATCH 02/16] Rename hook files --- packages/circuit-ui/components/Anchor/Anchor.tsx | 2 +- packages/circuit-ui/components/Button/Button.tsx | 2 +- packages/circuit-ui/components/Carousel/Carousel.js | 2 +- packages/circuit-ui/components/Checkbox/Checkbox.tsx | 2 +- packages/circuit-ui/components/Modal/Modal.tsx | 2 +- packages/circuit-ui/components/Popover/Popover.tsx | 2 +- packages/circuit-ui/components/RadioButton/RadioButton.tsx | 2 +- packages/circuit-ui/components/Select/Select.tsx | 2 +- packages/circuit-ui/components/Selector/Selector.tsx | 2 +- packages/circuit-ui/components/Sidebar/Sidebar.js | 2 +- .../components/Sidebar/components/Aggregator/Aggregator.tsx | 2 +- .../components/Sidebar/components/NavItem/NavItem.js | 2 +- packages/circuit-ui/components/Step/Step.js | 2 +- packages/circuit-ui/components/Step/Step.spec.js | 4 ++-- .../components/Step/hooks/{use-step.js => useStep.js} | 4 ++-- .../Step/hooks/{use-step.spec.js => useStep.spec.js} | 2 +- packages/circuit-ui/components/Step/index.js | 2 +- packages/circuit-ui/components/Tag/Tag.tsx | 2 +- .../circuit-ui/components/Toggle/components/Switch/Switch.tsx | 2 +- .../{use-click-handler.spec.ts => useClickHandler.spec.ts} | 2 +- .../hooks/{use-click-handler.ts => useClickHandler.ts} | 2 +- .../hooks/{use-component-size.js => useComponentSize.js} | 2 +- .../{use-component-size.spec.js => useComponentSize.spec.js} | 2 +- 23 files changed, 25 insertions(+), 25 deletions(-) rename packages/circuit-ui/components/Step/hooks/{use-step.js => useStep.js} (98%) rename packages/circuit-ui/components/Step/hooks/{use-step.spec.js => useStep.spec.js} (99%) rename packages/circuit-ui/hooks/{use-click-handler.spec.ts => useClickHandler.spec.ts} (98%) rename packages/circuit-ui/hooks/{use-click-handler.ts => useClickHandler.ts} (95%) rename packages/circuit-ui/hooks/{use-component-size.js => useComponentSize.js} (97%) rename packages/circuit-ui/hooks/{use-component-size.spec.js => useComponentSize.spec.js} (97%) diff --git a/packages/circuit-ui/components/Anchor/Anchor.tsx b/packages/circuit-ui/components/Anchor/Anchor.tsx index c8362e280f..a723040e2b 100644 --- a/packages/circuit-ui/components/Anchor/Anchor.tsx +++ b/packages/circuit-ui/components/Anchor/Anchor.tsx @@ -22,7 +22,7 @@ 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; diff --git a/packages/circuit-ui/components/Button/Button.tsx b/packages/circuit-ui/components/Button/Button.tsx index 62e72411a9..521def3d67 100644 --- a/packages/circuit-ui/components/Button/Button.tsx +++ b/packages/circuit-ui/components/Button/Button.tsx @@ -35,7 +35,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; 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.tsx b/packages/circuit-ui/components/Modal/Modal.tsx index c2847b410c..da74d4b87a 100644 --- a/packages/circuit-ui/components/Modal/Modal.tsx +++ b/packages/circuit-ui/components/Modal/Modal.tsx @@ -23,7 +23,7 @@ import noScroll from 'no-scroll'; import IS_IOS from '../../util/ios'; import { isFunction } from '../../util/type-check'; -import useClickHandler from '../../hooks/use-click-handler'; +import { useClickHandler } from '../../hooks/useClickHandler'; type OnClose = (event?: MouseEvent | KeyboardEvent) => void; diff --git a/packages/circuit-ui/components/Popover/Popover.tsx b/packages/circuit-ui/components/Popover/Popover.tsx index 95c5f2f5ef..40387a3a29 100644 --- a/packages/circuit-ui/components/Popover/Popover.tsx +++ b/packages/circuit-ui/components/Popover/Popover.tsx @@ -35,7 +35,7 @@ import { useTheme } from 'emotion-theming'; import styled, { StyleProps } from '../../styles/styled'; import { listItem, shadow, typography } from '../../styles/style-mixins'; import { useComponents } from '../ComponentsContext'; -import useClickHandler from '../../hooks/use-click-handler'; +import { useClickHandler } from '../../hooks/useClickHandler'; import Hr from '../Hr'; export interface BaseProps { diff --git a/packages/circuit-ui/components/RadioButton/RadioButton.tsx b/packages/circuit-ui/components/RadioButton/RadioButton.tsx index 5bde1f2a96..be4c0cf869 100644 --- a/packages/circuit-ui/components/RadioButton/RadioButton.tsx +++ b/packages/circuit-ui/components/RadioButton/RadioButton.tsx @@ -24,7 +24,7 @@ import { focusOutline, } from '../../styles/style-mixins'; import { uniqueId } from '../../util/id'; -import useClickHandler from '../../hooks/use-click-handler'; +import { useClickHandler } from '../../hooks/useClickHandler'; export interface RadioButtonProps extends HTMLProps { /** diff --git a/packages/circuit-ui/components/Select/Select.tsx b/packages/circuit-ui/components/Select/Select.tsx index 2f9e083a46..d609e34718 100644 --- a/packages/circuit-ui/components/Select/Select.tsx +++ b/packages/circuit-ui/components/Select/Select.tsx @@ -27,7 +27,7 @@ import { inputOutline, } from '../../styles/style-mixins'; import { ReturnType } from '../../types/return-type'; -import useClickHandler from '../../hooks/use-click-handler'; +import { useClickHandler } from '../../hooks/useClickHandler'; import Label from '../Label'; import ValidationHint from '../ValidationHint'; diff --git a/packages/circuit-ui/components/Selector/Selector.tsx b/packages/circuit-ui/components/Selector/Selector.tsx index 08a9846bcf..bf5272116b 100644 --- a/packages/circuit-ui/components/Selector/Selector.tsx +++ b/packages/circuit-ui/components/Selector/Selector.tsx @@ -21,7 +21,7 @@ import { Theme } from '@sumup/design-tokens'; import styled, { StyleProps } from '../../styles/styled'; import { hideVisually, disableVisually } from '../../styles/style-mixins'; import { uniqueId } from '../../util/id'; -import useClickHandler from '../../hooks/use-click-handler'; +import { useClickHandler } from '../../hooks/useClickHandler'; export type SelectorSize = 'kilo' | 'mega' | 'flexible'; diff --git a/packages/circuit-ui/components/Sidebar/Sidebar.js b/packages/circuit-ui/components/Sidebar/Sidebar.js index d5ca0b9ceb..cef37d4220 100644 --- a/packages/circuit-ui/components/Sidebar/Sidebar.js +++ b/packages/circuit-ui/components/Sidebar/Sidebar.js @@ -19,7 +19,7 @@ import styled from '@emotion/styled'; import { css } from '@emotion/core'; import { TrackingElement } from '@sumup/collector'; -import useClickHandler from '../../hooks/use-click-handler'; +import { useClickHandler } from '../../hooks/useClickHandler'; import Header from './components/Header'; import Footer from './components/Footer'; diff --git a/packages/circuit-ui/components/Sidebar/components/Aggregator/Aggregator.tsx b/packages/circuit-ui/components/Sidebar/components/Aggregator/Aggregator.tsx index ed2546013e..d115fec4d2 100644 --- a/packages/circuit-ui/components/Sidebar/components/Aggregator/Aggregator.tsx +++ b/packages/circuit-ui/components/Sidebar/components/Aggregator/Aggregator.tsx @@ -33,7 +33,7 @@ import styled, { StyleProps } from '../../../../styles/styled'; import SubNavList from '../SubNavList'; import BaseNavLabel from '../NavLabel'; import { hasSelectedChild, getIcon } from '../NavItem/utils'; -import useClickHandler from '../../../../hooks/use-click-handler'; +import { useClickHandler } from '../../../../hooks/useClickHandler'; export interface AggregatorProps { /** diff --git a/packages/circuit-ui/components/Sidebar/components/NavItem/NavItem.js b/packages/circuit-ui/components/Sidebar/components/NavItem/NavItem.js index 951e4c36f7..30fab5a122 100644 --- a/packages/circuit-ui/components/Sidebar/components/NavItem/NavItem.js +++ b/packages/circuit-ui/components/Sidebar/components/NavItem/NavItem.js @@ -18,7 +18,7 @@ import styled from '@emotion/styled'; import { css } from '@emotion/core'; import isPropValid from '@emotion/is-prop-valid'; -import useClickHandler from '../../../../hooks/use-click-handler'; +import { useClickHandler } from '../../../../hooks/useClickHandler'; import { useComponents } from '../../../ComponentsContext'; import NavLabel from '../NavLabel'; diff --git a/packages/circuit-ui/components/Step/Step.js b/packages/circuit-ui/components/Step/Step.js index ad1f2dc355..e7a6929368 100644 --- a/packages/circuit-ui/components/Step/Step.js +++ b/packages/circuit-ui/components/Step/Step.js @@ -16,7 +16,7 @@ import PropTypes from 'prop-types'; import { isFunction } from 'lodash/fp'; -import useStep from './hooks/use-step'; +import { useStep } from './hooks/useStep'; function Step({ children, diff --git a/packages/circuit-ui/components/Step/Step.spec.js b/packages/circuit-ui/components/Step/Step.spec.js index eaaf4c1c5d..d23c1fdf83 100644 --- a/packages/circuit-ui/components/Step/Step.spec.js +++ b/packages/circuit-ui/components/Step/Step.spec.js @@ -14,9 +14,9 @@ */ import Step from './Step'; -import useStep from './hooks/use-step'; +import { useStep } from './hooks/useStep'; -jest.mock('./hooks/use-step', () => jest.fn(() => ({}))); +jest.mock('./hooks/useStep', () => ({ useStep: jest.fn(() => ({})) })); describe('Step', () => { afterAll(() => { diff --git a/packages/circuit-ui/components/Step/hooks/use-step.js b/packages/circuit-ui/components/Step/hooks/useStep.js similarity index 98% rename from packages/circuit-ui/components/Step/hooks/use-step.js rename to packages/circuit-ui/components/Step/hooks/useStep.js index ab997c486a..ead3498e6d 100644 --- a/packages/circuit-ui/components/Step/hooks/use-step.js +++ b/packages/circuit-ui/components/Step/hooks/useStep.js @@ -17,9 +17,9 @@ import { useReducer, useEffect, useRef } from 'react'; import { isFunction } from 'lodash/fp'; import * as StepService from '../StepService'; -import useClickHandler from '../../../hooks/use-click-handler'; +import { useClickHandler } from '../../../hooks/useClickHandler'; -export default function useStep(props = {}) { +export function useStep(props = {}) { if ( process.env.NODE_ENV !== 'production' && props.cycle && diff --git a/packages/circuit-ui/components/Step/hooks/use-step.spec.js b/packages/circuit-ui/components/Step/hooks/useStep.spec.js similarity index 99% rename from packages/circuit-ui/components/Step/hooks/use-step.spec.js rename to packages/circuit-ui/components/Step/hooks/useStep.spec.js index b7daf1e001..071bf04b34 100644 --- a/packages/circuit-ui/components/Step/hooks/use-step.spec.js +++ b/packages/circuit-ui/components/Step/hooks/useStep.spec.js @@ -15,7 +15,7 @@ import { renderHook, act } from '@testing-library/react-hooks'; -import useStep from './use-step'; +import { useStep } from './useStep'; describe('useStep', () => { it('should return state based on default values', () => { diff --git a/packages/circuit-ui/components/Step/index.js b/packages/circuit-ui/components/Step/index.js index 550d3979d5..f5ffe4e050 100644 --- a/packages/circuit-ui/components/Step/index.js +++ b/packages/circuit-ui/components/Step/index.js @@ -13,7 +13,7 @@ * limitations under the License. */ -import useStep from './hooks/use-step'; +import { useStep } from './hooks/useStep'; import Step from './Step'; export { useStep }; diff --git a/packages/circuit-ui/components/Tag/Tag.tsx b/packages/circuit-ui/components/Tag/Tag.tsx index 948af04139..5b4febcea4 100644 --- a/packages/circuit-ui/components/Tag/Tag.tsx +++ b/packages/circuit-ui/components/Tag/Tag.tsx @@ -20,7 +20,7 @@ import { Theme } from '@sumup/design-tokens'; import styled, { StyleProps } from '../../styles/styled'; import { typography, focusOutline } from '../../styles/style-mixins'; -import useClickHandler from '../../hooks/use-click-handler'; +import { useClickHandler } from '../../hooks/useClickHandler'; import { CloseButton, CloseButtonProps } from '../CloseButton/CloseButton'; type BaseProps = { diff --git a/packages/circuit-ui/components/Toggle/components/Switch/Switch.tsx b/packages/circuit-ui/components/Toggle/components/Switch/Switch.tsx index 901882a723..fb6774671b 100644 --- a/packages/circuit-ui/components/Toggle/components/Switch/Switch.tsx +++ b/packages/circuit-ui/components/Toggle/components/Switch/Switch.tsx @@ -19,7 +19,7 @@ import { Dispatch as TrackingProps } from '@sumup/collector'; import styled, { StyleProps } from '../../../../styles/styled'; import { focusOutline, hideVisually } from '../../../../styles/style-mixins'; -import useClickHandler from '../../../../hooks/use-click-handler'; +import { useClickHandler } from '../../../../hooks/useClickHandler'; export interface SwitchProps extends Omit, 'type'> { diff --git a/packages/circuit-ui/hooks/use-click-handler.spec.ts b/packages/circuit-ui/hooks/useClickHandler.spec.ts similarity index 98% rename from packages/circuit-ui/hooks/use-click-handler.spec.ts rename to packages/circuit-ui/hooks/useClickHandler.spec.ts index dfaf1f3e52..5b0e0eed4f 100644 --- a/packages/circuit-ui/hooks/use-click-handler.spec.ts +++ b/packages/circuit-ui/hooks/useClickHandler.spec.ts @@ -16,7 +16,7 @@ import { renderHook, act } from '@testing-library/react-hooks'; import * as Collector from '@sumup/collector'; -import useClickHandler from './use-click-handler'; +import { useClickHandler } from './useClickHandler'; jest.mock('@sumup/collector'); diff --git a/packages/circuit-ui/hooks/use-click-handler.ts b/packages/circuit-ui/hooks/useClickHandler.ts similarity index 95% rename from packages/circuit-ui/hooks/use-click-handler.ts rename to packages/circuit-ui/hooks/useClickHandler.ts index 9b5c8965f1..4ba45e27b9 100644 --- a/packages/circuit-ui/hooks/use-click-handler.ts +++ b/packages/circuit-ui/hooks/useClickHandler.ts @@ -15,7 +15,7 @@ import { useClickTrigger, Dispatch } from '@sumup/collector'; -export default function useClickHandler( +export function useClickHandler( onClick?: (event: Event) => void, tracking?: Dispatch, defaultComponentName?: string, diff --git a/packages/circuit-ui/hooks/use-component-size.js b/packages/circuit-ui/hooks/useComponentSize.js similarity index 97% rename from packages/circuit-ui/hooks/use-component-size.js rename to packages/circuit-ui/hooks/useComponentSize.js index df2d3eb95d..793f035200 100644 --- a/packages/circuit-ui/hooks/use-component-size.js +++ b/packages/circuit-ui/hooks/useComponentSize.js @@ -30,7 +30,7 @@ function getSize(el) { }; } -export default function useComponentSize(ref = {}) { +export function useComponentSize(ref = {}) { const [componentSize, setComponentSize] = useState(getSize(ref.current)); const handleResize = useCallback( throttle(500)(() => { diff --git a/packages/circuit-ui/hooks/use-component-size.spec.js b/packages/circuit-ui/hooks/useComponentSize.spec.js similarity index 97% rename from packages/circuit-ui/hooks/use-component-size.spec.js rename to packages/circuit-ui/hooks/useComponentSize.spec.js index 3eae3de487..796723c475 100644 --- a/packages/circuit-ui/hooks/use-component-size.spec.js +++ b/packages/circuit-ui/hooks/useComponentSize.spec.js @@ -15,7 +15,7 @@ import { renderHook, act } from '@testing-library/react-hooks'; -import useComponentSize from './use-component-size'; +import { useComponentSize } from './useComponentSize'; jest.mock('lodash/fp/throttle', () => jest.fn(() => (fn) => { From 9621f0652ac6d8266b21b3e4d0a3d50f6dd6a23e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Connor=20B=C3=A4r?= Date: Thu, 20 May 2021 10:26:44 +0200 Subject: [PATCH 03/16] Add useStack hook --- packages/circuit-ui/hooks/useStack.spec.ts | 122 +++++++++++++++++++++ packages/circuit-ui/hooks/useStack.ts | 87 +++++++++++++++ 2 files changed, 209 insertions(+) create mode 100644 packages/circuit-ui/hooks/useStack.spec.ts create mode 100644 packages/circuit-ui/hooks/useStack.ts diff --git a/packages/circuit-ui/hooks/useStack.spec.ts b/packages/circuit-ui/hooks/useStack.spec.ts new file mode 100644 index 0000000000..5086914d72 --- /dev/null +++ b/packages/circuit-ui/hooks/useStack.spec.ts @@ -0,0 +1,122 @@ +/** + * 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 { renderHook, act } from '@testing-library/react-hooks'; + +import { useStack } from './useStack'; + +describe('useStack', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + const initialStack = [{ id: 1 }, { id: 2 }, { id: 3 }]; + + describe('initialization', () => { + it('should initialize the stack to an empty array', () => { + const { result } = renderHook(() => useStack()); + + const state = result.current[0]; + + expect(state).toEqual([]); + }); + + it('should initialize the stack with an initial value', () => { + const { result } = renderHook(() => useStack(initialStack)); + + const state = result.current[0]; + + expect(state).toEqual(initialStack); + }); + }); + + describe('actions', () => { + it('should push an item to the top of the stack', () => { + const { result } = renderHook(() => useStack(initialStack)); + + act(() => { + const dispatch = result.current[1]; + dispatch({ type: 'push', item: { id: 4 } }); + }); + + const state = result.current[0]; + + expect(state).toHaveLength(4); + expect(state[3].id).toBe(4); + }); + + it('should pop an item from the top of the stack', () => { + const { result } = renderHook(() => useStack(initialStack)); + + act(() => { + const dispatch = result.current[1]; + dispatch({ type: 'pop' }); + }); + + expect(result.current[0]).toHaveLength(2); + }); + + it('should pop an item from the top of the stack after a delay', () => { + const timeout = 200; + const { result } = renderHook(() => useStack(initialStack)); + + act(() => { + const dispatch = result.current[1]; + dispatch({ type: 'pop', timeout }); + }); + + expect(result.current[0]).toHaveLength(3); + + act(() => { + jest.advanceTimersByTime(timeout); + }); + + expect(result.current[0]).toHaveLength(2); + }); + + it('should remove an item from the stack', () => { + const { result } = renderHook(() => useStack(initialStack)); + + act(() => { + const dispatch = result.current[1]; + dispatch({ type: 'remove', id: 2 }); + }); + + expect(result.current[0]).toHaveLength(2); + }); + + it('should remove an item from the stack after a delay', () => { + const timeout = 200; + const { result } = renderHook(() => useStack(initialStack)); + + act(() => { + const dispatch = result.current[1]; + dispatch({ type: 'remove', id: 2, timeout }); + }); + + expect(result.current[0]).toHaveLength(3); + + act(() => { + jest.advanceTimersByTime(200); + }); + + expect(result.current[0]).toHaveLength(2); + }); + }); +}); diff --git a/packages/circuit-ui/hooks/useStack.ts b/packages/circuit-ui/hooks/useStack.ts new file mode 100644 index 0000000000..0ba6574cbc --- /dev/null +++ b/packages/circuit-ui/hooks/useStack.ts @@ -0,0 +1,87 @@ +/** + * Copyright 2021, 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 { Dispatch, useEffect, useReducer } from 'react'; + +type Id = string | number; + +export type StackItem = { + id: Id; + timeout?: number; +}; + +type Action = + | { type: 'push'; item: T } + | { type: 'pop'; timeout?: number } + | { type: 'remove'; id: Id; timeout?: number }; + +export type StackDispatch = Dispatch>; + +function createReducer() { + return (state: T[], action: Action) => { + switch (action.type) { + case 'push': { + return [...state, action.item]; + } + case 'pop': { + const firstItems = state.slice(0, -1); + + if (action.timeout) { + const lastItem = { + ...state[state.length - 1], + timeout: action.timeout, + }; + return [...firstItems, lastItem]; + } + + return firstItems; + } + case 'remove': { + if (action.timeout) { + return state.map((s) => + s.id !== action.id ? s : { ...s, timeout: action.timeout }, + ); + } + + return state.filter((s) => s.id !== action.id); + } + default: { + return state; + } + } + }; +} + +export function useStack( + initialStack: T[] = [], +): [T[], StackDispatch] { + const reducer = createReducer(); + + const [state, dispatch] = useReducer(reducer, initialStack); + + useEffect(() => { + const itemToRemove = state.find((item) => item.timeout); + + if (!itemToRemove) { + return; + } + + setTimeout(() => { + dispatch({ type: 'remove', id: itemToRemove.id }); + }, itemToRemove.timeout); + }, [state, dispatch]); + + return [state, dispatch]; +} From 1a6f4600e11b7ad4b5996500a44087be1a164950 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Connor=20B=C3=A4r?= Date: Wed, 9 Jun 2021 13:11:29 +0200 Subject: [PATCH 04/16] Implement ModalContext --- .../components/Modal/Modal.embed.stories.tsx | 118 ------- .../components/Modal/Modal.spec.tsx | 40 +-- .../components/Modal/Modal.stories.tsx | 307 ++++++++++-------- .../circuit-ui/components/Modal/Modal.tsx | 33 +- .../components/Modal/ModalContext.tsx | 118 ------- packages/circuit-ui/components/Modal/index.ts | 6 +- .../circuit-ui/components/Modal/useModal.ts | 20 ++ .../ModalContext/ModalContext.spec.tsx | 26 ++ .../components/ModalContext/ModalContext.tsx | 206 ++++++++++++ .../components/ModalContext/index.ts | 22 ++ packages/circuit-ui/index.ts | 4 +- 11 files changed, 470 insertions(+), 430 deletions(-) delete mode 100644 packages/circuit-ui/components/Modal/Modal.embed.stories.tsx delete mode 100644 packages/circuit-ui/components/Modal/ModalContext.tsx create mode 100644 packages/circuit-ui/components/Modal/useModal.ts create mode 100644 packages/circuit-ui/components/ModalContext/ModalContext.spec.tsx create mode 100644 packages/circuit-ui/components/ModalContext/ModalContext.tsx create mode 100644 packages/circuit-ui/components/ModalContext/index.ts 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..1030b51bb4 100644 --- a/packages/circuit-ui/components/Modal/Modal.spec.tsx +++ b/packages/circuit-ui/components/Modal/Modal.spec.tsx @@ -15,9 +15,10 @@ import { render, act, userEvent, waitFor } from '../../util/test-utils'; import Button from '../Button'; +import { ModalProvider } from '../ModalContext'; import { ModalProps } from './Modal'; -import { ModalConsumer, ModalProvider } from './ModalContext'; +import { useModal } from './useModal'; import * as MockedModal from './Modal'; describe('Modal', () => { @@ -26,28 +27,23 @@ describe('Modal', () => { (MockedModal as any).TRANSITION_DURATION = 0; }); - // eslint-disable-next-line react/prop-types + const SetModal = ({ modal }: { modal: ModalProps }) => { + const { setModal } = useModal(); + return ( + + ); + }; + const PageWithModal = ({ modal }: { modal: ModalProps }) => ( -
- - - {({ setModal }) => ( - - )} - - -
+ + + ); const defaultModal: ModalProps = { diff --git a/packages/circuit-ui/components/Modal/Modal.stories.tsx b/packages/circuit-ui/components/Modal/Modal.stories.tsx index 80a35b888d..4a6e3f921c 100644 --- a/packages/circuit-ui/components/Modal/Modal.stories.tsx +++ b/packages/circuit-ui/components/Modal/Modal.stories.tsx @@ -21,11 +21,12 @@ import { action } from '@storybook/addon-actions'; import Button from '../Button'; import ButtonGroup from '../ButtonGroup'; import Body from '../Body'; +import { ModalProvider } from '../ModalContext'; 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', @@ -35,148 +36,176 @@ export default { }, }; -/* eslint-disable react/display-name, react/prop-types */ - -const PageWithModal = (modal: ModalProps) => ( - - - {({ setModal }) => ( - - )} - - -); - -const defaultModal = { +export const Base = (modal: ModalProps): JSX.Element => { + const ComponentWithModal = () => { + const { setModal } = useModal(); + + return ( + + ); + }; + return ( + + + + ); +}; + +Base.args = { children: () => Hello World!, - onClose: () => {}, }; -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'); - `; +export const Multiple = (modal: ModalProps) => { + const ComponentWithModal = () => { + const { setModal } = useModal(); + return ( + + ); + }; + + return ( + + + + ); +}; +const NestedModal = () => { + const { setModal } = useModal(); return ( - - {() => ( -
- - - A nice custom modal for special cases. - - - -
- )} -
+ + + ); }; + +Multiple.args = { + children: () => , +}; + +// 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'); +// `; + +// return ( +// +// {() => ( +//
+// +// +// A nice custom modal for special cases. +// +// +// +//
+// )} +//
+// ); +// }; diff --git a/packages/circuit-ui/components/Modal/Modal.tsx b/packages/circuit-ui/components/Modal/Modal.tsx index da74d4b87a..1b959de927 100644 --- a/packages/circuit-ui/components/Modal/Modal.tsx +++ b/packages/circuit-ui/components/Modal/Modal.tsx @@ -14,40 +14,23 @@ */ import { FC, MouseEvent, KeyboardEvent, ReactNode } from 'react'; -import ReactModal, { Props } from 'react-modal'; +import ReactModal, { Props as ReactModalProps } from 'react-modal'; import { ClassNames } from '@emotion/core'; import { useTheme } from 'emotion-theming'; 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/useClickHandler'; type OnClose = (event?: MouseEvent | KeyboardEvent) => void; -export interface ModalProps extends Partial { +export interface ModalProps extends Omit { 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. + * 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. */ @@ -55,7 +38,6 @@ export interface ModalProps extends Partial { } export const TRANSITION_DURATION = 200; -export const DEFAULT_APP_ELEMENT = '#root'; const TOP_MARGIN = '10vh'; const TRANSFORM_Y_FLOATING = '10vh'; @@ -73,15 +55,12 @@ export const Modal: FC = ({ children, onClose, contentLabel = 'Modal', - appElement = DEFAULT_APP_ELEMENT, - isOpen = true, tracking = {}, ...props }) => { const theme: Theme = useTheme(); const handleClose = useClickHandler(onClose, tracking, 'modal-close') || onClose; - ReactModal.setAppElement(appElement); return ( {({ css }) => { @@ -178,17 +157,15 @@ export const Modal: FC = ({ }; const reactModalProps = { - isOpen, + isOpen: true, className, overlayClassName, - htmlOpenClassName: 'ReactModal__Html--open', contentLabel, - onAfterOpen: () => IS_IOS && noScroll.on(), - onAfterClose: () => IS_IOS && noScroll.off(), onRequestClose: handleClose, closeTimeoutMS: TRANSITION_DURATION, ...props, }; + return ( {isFunction(children) 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/index.ts b/packages/circuit-ui/components/Modal/index.ts index 26b4e918b8..c406f0fc0a 100644 --- a/packages/circuit-ui/components/Modal/index.ts +++ b/packages/circuit-ui/components/Modal/index.ts @@ -1,5 +1,5 @@ /** - * Copyright 2019, SumUp Ltd. + * Copyright 2021, 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 @@ -13,7 +13,5 @@ * limitations under the License. */ -export { ModalProvider, useModal } from './ModalContext'; -export { Modal } from './Modal'; - +export { useModal } from './useModal'; export type { ModalProps } from './Modal'; diff --git a/packages/circuit-ui/components/Modal/useModal.ts b/packages/circuit-ui/components/Modal/useModal.ts new file mode 100644 index 0000000000..f057973d46 --- /dev/null +++ b/packages/circuit-ui/components/Modal/useModal.ts @@ -0,0 +1,20 @@ +/** + * Copyright 2021, 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 { createUseModal } from '../ModalContext'; + +import { Modal } from './Modal'; + +export const useModal = createUseModal(Modal); diff --git a/packages/circuit-ui/components/ModalContext/ModalContext.spec.tsx b/packages/circuit-ui/components/ModalContext/ModalContext.spec.tsx new file mode 100644 index 0000000000..cf33e21407 --- /dev/null +++ b/packages/circuit-ui/components/ModalContext/ModalContext.spec.tsx @@ -0,0 +1,26 @@ +/** + * Copyright 2021, 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 React from 'react'; + +// import { render, act, userEvent, waitFor } from '../../util/test-utils'; +// import Button from '../Button'; + +// import { ModalProps } from './Modal'; +// import { ModalProvider, useModal } from './ModalContext'; + +describe('ModalContext', () => {}); + +export const tmp = 'tmp'; diff --git a/packages/circuit-ui/components/ModalContext/ModalContext.tsx b/packages/circuit-ui/components/ModalContext/ModalContext.tsx new file mode 100644 index 0000000000..3847f3e03c --- /dev/null +++ b/packages/circuit-ui/components/ModalContext/ModalContext.tsx @@ -0,0 +1,206 @@ +/** + * 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, + useContext, + useEffect, + useMemo, + useCallback, + useDebugValue, + FC, + MouseEvent, + KeyboardEvent, +} from 'react'; +import ReactModal, { Props as ReactModalProps } from 'react-modal'; +import { Global, css } from '@emotion/core'; +// import noScroll from 'no-scroll'; +import { Dispatch as TrackingProps } from '@sumup/collector'; + +import { useStack, StackItem, StackDispatch } from '../../hooks/useStack'; +import { uniqueId } from '../../util/id'; +// import IS_IOS from '../../util/ios'; + +// TODO: Add explainer what this does. +if (typeof window !== 'undefined') { + // These are the default app elements in Next.js and CRA. + const appElement = + document.getElementById('__next') || document.getElementById('root'); + + if (appElement) { + ReactModal.setAppElement(appElement); + } else if (process.env.NODE_ENV !== 'production') { + // TODO: Add error message + console.error(''); + } +} + +export type OnClose = (event?: MouseEvent | KeyboardEvent) => void; + +export interface BaseModalProps + extends Omit< + ReactModalProps, + 'shouldCloseOnOverlayClick' | 'shouldCloseOnEsc' + > { + /** + * Callback function that is called when the modal is closed. + */ + onClose?: OnClose; + /** + * Additional data that is dispatched with the tracking event. + */ + tracking?: TrackingProps; +} + +export type ModalComponent = (( + props: T, +) => JSX.Element) & { TIMEOUT?: number }; + +type ModalState = Omit & + StackItem & { component: ModalComponent }; + +type ModalContextValue = [ModalState[], StackDispatch]; + +const ModalContext = createContext([[], () => {}]); + +export interface ModalProviderProps extends Omit { + initialState?: ModalState[]; +} + +export const ModalProvider: FC = ({ + children, + initialState, + portalClassName = 'ReactModalPortal', + htmlOpenClassName = 'ReactModal__Html--open', + ...defaultModalProps +}) => { + const [modals, dispatch] = useStack(initialState); + + const isOpen = modals.length > 0; + + useEffect(() => { + const popModal = () => { + dispatch({ type: 'pop' }); + }; + + if (isOpen) { + window.onpopstate = popModal; + } else { + window.onpopstate = null; + } + + return () => { + window.onpopstate = null; + }; + }, [dispatch, isOpen]); + + // TODO: Not sure this even works or is still needed. + // useEffect(() => { + // if (!IS_IOS) { + // return undefined; + // } + + // if (isOpen) { + // noScroll.on(); + // } else { + // noScroll.off(); + // } + + // return () => { + // noScroll.off(); + // }; + // }, [isOpen]); + + return ( + + {children} + + {modals.map( + ({ id, onClose, timeout, component: Component, ...modalProps }) => { + const handleClose = (event?: MouseEvent | KeyboardEvent) => { + if (onClose) { + onClose(event); + } + dispatch({ type: 'remove', id, timeout: Component.TIMEOUT }); + }; + return ( + + ); + }, + )} + + {isOpen && ( + + )} + + ); +}; + +export function createUseModal( + component: ModalComponent, +) { + return (): { + setModal: (props: Omit) => void; + removeModal: () => void; + } => { + const id = useMemo(uniqueId, []); + const [modals, dispatch] = useContext(ModalContext); + + const modal = useMemo(() => modals.find((m) => m.id === id), [id, modals]); + + useDebugValue(modal); + + const setModal = useCallback( + (props: Omit): void => { + // @ts-expect-error There's only the base type and one subtype, + // so this warning can be safely ignored. + dispatch({ type: 'push', item: { ...props, id, component } }); + }, + [dispatch, id], + ); + + const removeModal = useCallback((): void => { + if (modal && modal.onClose) { + modal.onClose(); + } + dispatch({ type: 'remove', id, timeout: component.TIMEOUT }); + }, [dispatch, id, modal]); + + return { setModal, removeModal }; + }; +} diff --git a/packages/circuit-ui/components/ModalContext/index.ts b/packages/circuit-ui/components/ModalContext/index.ts new file mode 100644 index 0000000000..3aac11d475 --- /dev/null +++ b/packages/circuit-ui/components/ModalContext/index.ts @@ -0,0 +1,22 @@ +/** + * 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. + */ + +export { ModalProvider, createUseModal } from './ModalContext'; + +export type { + ModalProviderProps, + BaseModalProps, + ModalComponent, +} from './ModalContext'; diff --git a/packages/circuit-ui/index.ts b/packages/circuit-ui/index.ts index 2b8c2d816c..bba0f13fbc 100644 --- a/packages/circuit-ui/index.ts +++ b/packages/circuit-ui/index.ts @@ -121,7 +121,9 @@ export type { TagProps } from './components/Tag'; export { default as Popover } from './components/Popover'; export { default as Tooltip } from './components/Tooltip'; export { default as BaseStyles } from './components/BaseStyles'; -export { ModalProvider, useModal } from './components/Modal'; +export { ModalProvider } from './components/ModalContext'; +export type { ModalProviderProps } from './components/ModalContext'; +export { useModal } from './components/Modal'; export type { ModalProps } from './components/Modal'; export { default as Table } from './components/Table'; From e65786b15b19f649975a24ece6e659b289955704 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Connor=20B=C3=A4r?= Date: Thu, 10 Jun 2021 17:39:08 +0200 Subject: [PATCH 05/16] Refactor Modal component --- .../components/Modal/Modal.docs.mdx | 31 --- .../components/Modal/Modal.spec.tsx | 99 ++----- .../components/Modal/Modal.stories.tsx | 220 ++++++--------- .../circuit-ui/components/Modal/Modal.tsx | 223 +++++++++------ .../Modal/__snapshots__/Modal.spec.tsx.snap | 256 ++++++++++++++++++ 5 files changed, 502 insertions(+), 327 deletions(-) create mode 100644 packages/circuit-ui/components/Modal/__snapshots__/Modal.spec.tsx.snap diff --git a/packages/circuit-ui/components/Modal/Modal.docs.mdx b/packages/circuit-ui/components/Modal/Modal.docs.mdx index a04845d049..c68104813c 100644 --- a/packages/circuit-ui/components/Modal/Modal.docs.mdx +++ b/packages/circuit-ui/components/Modal/Modal.docs.mdx @@ -89,34 +89,3 @@ const Page = () => { - `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 - -If you prefer to embed the code declaratively inside the component, you can do it as such: - -```js -import { Modal, ModalWrapper, ModalHeader, Button } from '@sumup/circuit-ui'; - -const Page = () => { - const [isModalOpen, setModalOpen] = useState(false); - - const toggleModal = () => { - setModalOpen((prev) => !prev); - }; - - return ( - - - - - {({ onClose }) => ( - - - The modal is open! - - )} - - - ); -}; -``` diff --git a/packages/circuit-ui/components/Modal/Modal.spec.tsx b/packages/circuit-ui/components/Modal/Modal.spec.tsx index 1030b51bb4..f786014e8d 100644 --- a/packages/circuit-ui/components/Modal/Modal.spec.tsx +++ b/packages/circuit-ui/components/Modal/Modal.spec.tsx @@ -13,93 +13,52 @@ * limitations under the License. */ -import { render, act, userEvent, waitFor } from '../../util/test-utils'; -import Button from '../Button'; -import { ModalProvider } from '../ModalContext'; +import { render, act, userEvent, axe } from '../../util/test-utils'; -import { ModalProps } from './Modal'; -import { useModal } from './useModal'; -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; - }); - - const SetModal = ({ modal }: { modal: ModalProps }) => { - const { setModal } = useModal(); - return ( - - ); - }; - - const PageWithModal = ({ modal }: { modal: ModalProps }) => ( - - - - ); - const defaultModal: ModalProps = { + isOpen: true, + labelCloseButton: '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 4a6e3f921c..6155785ce8 100644 --- a/packages/circuit-ui/components/Modal/Modal.stories.tsx +++ b/packages/circuit-ui/components/Modal/Modal.stories.tsx @@ -13,18 +13,19 @@ * 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 { Modal, ModalProps } from './Modal'; import { useModal } from './useModal'; @@ -54,10 +55,35 @@ export const Base = (modal: ModalProps): JSX.Element => { }; Base.args = { - children: () => Hello World!, + children: 'Hello World!', + variant: 'contextual', }; -export const Multiple = (modal: ModalProps) => { +export const Variants = (modal: ModalProps): JSX.Element => { + const ComponentWithModal = ({ variant }: Pick) => { + const { setModal } = useModal(); + + return ( + + ); + }; + return ( + + + + + + + ); +}; + +Variants.args = { + children: 'Hello World!', +}; + +export const NotDismissible = (modal: ModalProps): JSX.Element => { const ComponentWithModal = () => { const { setModal } = useModal(); return ( @@ -74,138 +100,58 @@ export const Multiple = (modal: ModalProps) => { ); }; -const NestedModal = () => { - const { setModal } = useModal(); - return ( - - - - ); + + ), + variant: 'immersive', + dismissible: false, }; -Multiple.args = { - children: () => , +export const CustomStyles = (modal: ModalProps): JSX.Element => { + const ComponentWithModal = () => { + const { setModal } = useModal(); + return ( + + ); + }; + + return ( + + + + ); }; -// 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'); -// `; - -// return ( -// -// {() => ( -//
-// -// -// A nice custom modal for special cases. -// -// -// -//
-// )} -//
-// ); -// }; +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', +}; diff --git a/packages/circuit-ui/components/Modal/Modal.tsx b/packages/circuit-ui/components/Modal/Modal.tsx index 1b959de927..8342db8f12 100644 --- a/packages/circuit-ui/components/Modal/Modal.tsx +++ b/packages/circuit-ui/components/Modal/Modal.tsx @@ -13,165 +13,208 @@ * limitations under the License. */ -import { FC, MouseEvent, KeyboardEvent, ReactNode } from 'react'; -import ReactModal, { Props as ReactModalProps } 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 { isFunction } from '../../util/type-check'; import { useClickHandler } from '../../hooks/useClickHandler'; - -type OnClose = (event?: MouseEvent | KeyboardEvent) => void; - -export interface ModalProps extends Omit { - children: ReactNode | (({ onClose }: { onClose?: OnClose }) => ReactNode); +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}; + } +`; + +export interface ModalProps extends BaseModalProps { + /** + * TODO: Add description + */ + children: + | ReactNode + | (({ onClose }: { onClose: BaseModalProps['onClose'] }) => ReactNode); + /** + * TODO: Add description + */ + variant: 'immersive' | 'contextual'; /** - * Function to close the modal. Passed down to the children render prop. + * Text label for the close button for screen readers. + * Important for accessibility. */ - onClose?: OnClose; + labelCloseButton: string; /** - * Additional data that is dispatched with the tracking event. + * TODO: Add description. Default true. */ - tracking?: TrackingProps; + dismissible?: boolean; + className?: string; } -export const TRANSITION_DURATION = 200; - -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)`; - /** - * 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. + * Circuit UI's wrapper component for ReactModal. * http://reactcommunity.org/react-modal/accessibility/#aria */ -export const Modal: FC = ({ +export const Modal: ModalComponent = ({ children, onClose, - contentLabel = 'Modal', + variant, + dismissible = true, + labelCloseButton, tracking = {}, + className, ...props }) => { - const theme: Theme = useTheme(); - const handleClose = - useClickHandler(onClose, tracking, 'modal-close') || onClose; + const handleClose = useClickHandler(onClose, tracking, 'modal-close'); return ( - - {({ css }) => { + > + {({ 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; + // FIXME: Replace border-radius with theme value in v3. + const styles = { + base: cx( + cssString` + label: modal; 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: 16px; + } + `, + variant === 'immersive' && + cssString` + label: modal--immersive; + + ${theme.mq.untilKilo} { + height: 100vh; + } + `, + variant === 'contextual' && + cssString` + label: modal--contextual; + + ${theme.mq.untilKilo} { + max-height: calc(100vh - ${theme.spacings.mega}); + border-top-left-radius: 16px; + border-top-right-radius: 16px; + } + `, + className, + ), + afterOpen: cssString` label: modal--after-open; + ${theme.mq.untilKilo} { transform: translateY(0); } ${theme.mq.kilo} { opacity: 1; - transform: translateY(0); } `, - 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` + const overlayStyles = { + base: cssString` label: modal__overlay; - background: ${theme.colors.overlay}; - bottom: 0; - left: 0; - opacity: 0; 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` + afterOpen: cssString` label: modal__overlay--after-open; opacity: 1; `, - beforeClose: css` + beforeClose: cssString` label: modal__overlay--before-close; opacity: 0; `, }; const reactModalProps = { - isOpen: true, - className, - overlayClassName, - contentLabel, + className: styles, + overlayClassName: overlayStyles, onRequestClose: handleClose, closeTimeoutMS: TRANSITION_DURATION, + shouldCloseOnOverlayClick: dismissible, + shouldCloseOnEsc: dismissible, ...props, }; return ( + {dismissible && ( + + )} + {isFunction(children) - ? children({ - onClose: handleClose, - }) + ? children({ onClose: handleClose }) : children} ); @@ -179,3 +222,5 @@ export const Modal: FC = ({ ); }; + +Modal.TIMEOUT = TRANSITION_DURATION; 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..7a214fac05 --- /dev/null +++ b/packages/circuit-ui/components/Modal/__snapshots__/Modal.spec.tsx.snap @@ -0,0 +1,256 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Modal should match the snapshot 1`] = ` +.circuit-4 { + position: fixed; + outline: none; + background-color: #FFF; + border-radius: 16px; + padding: 24px; +} + +@media (max-width:479px) { + .circuit-4 { + right: 0; + bottom: 0; + left: 0; + -webkit-overflow-scrolling: touch; + overflow-y: auto; + height: calc(100vh - 16px); + width: 100vw; + -webkit-transform: translateY(100%); + -ms-transform: translateY(100%); + transform: translateY(100%); + -webkit-transition: -webkit-transform 200ms ease-in-out; + -webkit-transition: transform 200ms ease-in-out; + transition: transform 200ms ease-in-out; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + } +} + +@media (min-width:480px) { + .circuit-4 { + top: 50%; + left: 50%; + -webkit-transform: translate(-50%,-50%); + -ms-transform: translate(-50%,-50%); + transform: translate(-50%,-50%); + height: 90vh; + width: 90vw; + opacity: 0; + -webkit-transition: opacity 200ms ease-in-out; + transition: opacity 200ms ease-in-out; + } +} + +@media (max-width:479px) { + .circuit-5 { + -webkit-transform: translateY(0); + -ms-transform: translateY(0); + transform: translateY(0); + } +} + +@media (min-width:480px) { + .circuit-5 { + opacity: 1; + } +} + +.circuit-6 { + position: fixed; + top: 0; + left: 0; + bottom: 0; + right: 0; + opacity: 0; + -webkit-transition: opacity 200ms ease-in-out; + transition: opacity 200ms ease-in-out; + background: rgba(0,0,0,0.4); + z-index: 1000; +} + +@media (min-width:480px) { + .circuit-6 { + -webkit-overflow-scrolling: touch; + overflow-y: auto; + } +} + +.circuit-7 { + opacity: 1; +} + +.circuit-3 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + -webkit-box-pack: justify; + -webkit-justify-content: space-between; + -ms-flex-pack: justify; + justify-content: space-between; + margin-bottom: 24px; +} + +.circuit-0 { + font-weight: 700; + margin-bottom: 24px; + color: #000; + font-size: 18px; + line-height: 24px; + margin-bottom: 0; +} + +.circuit-2 { + 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; + font-size: 16px; + line-height: 24px; + 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: 8px calc(24px - 1px); + padding: 12px; + border: 0; +} + +.circuit-2:focus { + outline: 0; + box-shadow: 0 0 0 4px #AFD0FE; +} + +.circuit-2:focus::-moz-focus-inner { + border: 0; +} + +.circuit-2:disabled, +.circuit-2[disabled] { + opacity: 0.5; + pointer-events: none; + box-shadow: none; +} + +.circuit-2:hover { + background-color: #F5F5F5; + border-color: #666; +} + +.circuit-2:active { + background-color: #E6E6E6; + border-color: #333; +} + +.circuit-1 { + 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; +} + + +
+
+
+
+
+ +
+
+ +`; From 45b2cd8f567f217c14801ff0388ef350763d09a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Connor=20B=C3=A4r?= Date: Thu, 10 Jun 2021 22:35:14 +0200 Subject: [PATCH 06/16] Improve types for onClick callback --- .../circuit-ui/components/Anchor/Anchor.tsx | 19 ++++++++++--- .../circuit-ui/components/Button/Button.tsx | 11 ++++++-- .../circuit-ui/components/Popover/Popover.tsx | 1 + packages/circuit-ui/components/Tag/Tag.tsx | 28 ++++++++++++++----- 4 files changed, 45 insertions(+), 14 deletions(-) diff --git a/packages/circuit-ui/components/Anchor/Anchor.tsx b/packages/circuit-ui/components/Anchor/Anchor.tsx index a723040e2b..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'; @@ -26,6 +33,10 @@ 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 521def3d67..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'; @@ -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/Popover/Popover.tsx b/packages/circuit-ui/components/Popover/Popover.tsx index 40387a3a29..be385ee755 100644 --- a/packages/circuit-ui/components/Popover/Popover.tsx +++ b/packages/circuit-ui/components/Popover/Popover.tsx @@ -21,6 +21,7 @@ import { HTMLProps, SVGProps, MouseEvent, + KeyboardEvent, Fragment, useMemo, RefObject, diff --git a/packages/circuit-ui/components/Tag/Tag.tsx b/packages/circuit-ui/components/Tag/Tag.tsx index 5b4febcea4..665302436a 100644 --- a/packages/circuit-ui/components/Tag/Tag.tsx +++ b/packages/circuit-ui/components/Tag/Tag.tsx @@ -13,7 +13,15 @@ * limitations under the License. */ -import { HTMLProps, Ref, FC, SVGProps, MouseEvent, forwardRef } from 'react'; +import { + HTMLProps, + Ref, + FC, + SVGProps, + MouseEvent, + KeyboardEvent, + forwardRef, +} from 'react'; import { css } from '@emotion/core'; import { Dispatch as TrackingProps } from '@sumup/collector'; import { Theme } from '@sumup/design-tokens'; @@ -36,6 +44,10 @@ type BaseProps = { * Triggers selected styles on the tag. */ selected?: boolean; + /** + * Function that's called when the button is clicked. + */ + onClick?: (event: MouseEvent | KeyboardEvent) => void; /** * Additional data that is dispatched with the tracking event. */ @@ -52,7 +64,7 @@ type RemoveProps = * Renders a close button inside the tag and calls the provided function * when the button is clicked. */ - onRemove: (event: MouseEvent) => void; + onRemove: (event: MouseEvent | KeyboardEvent) => void; /** * Text label for the remove icon for screen readers. * Important for accessibility. @@ -61,8 +73,8 @@ type RemoveProps = } | { onRemove?: never; removeButtonLabel?: never }; -type DivElProps = Omit, 'prefix'>; -type ButtonElProps = Omit, 'prefix'>; +type DivElProps = Omit, 'prefix' | 'onClick'>; +type ButtonElProps = Omit, 'prefix' | 'onClick'>; export type TagProps = BaseProps & RemoveProps & DivElProps & ButtonElProps; @@ -204,9 +216,11 @@ export const Tag = forwardRef( ref: BaseProps['ref'], ) => { const as = onClick ? 'button' : 'div'; - const handleClick = useClickHandler< - MouseEvent - >(onClick, tracking, 'tag'); + const handleClick = useClickHandler( + onClick, + tracking, + 'tag', + ); return ( From 396e10a47452df4f7db12da4bff5b9a0f2301820 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Connor=20B=C3=A4r?= Date: Mon, 14 Jun 2021 18:19:56 +0200 Subject: [PATCH 07/16] Delete obsolete modal subcomponents --- .../components/Modal/Modal.docs.mdx | 68 +++---------------- .../ModalFooter/ModalFooter.spec.tsx | 37 ---------- .../components/ModalFooter/ModalFooter.tsx | 46 ------------- .../__snapshots__/ModalFooter.spec.tsx.snap | 59 ---------------- .../ModalHeader/ModalHeader.spec.tsx | 37 ---------- .../components/ModalHeader/ModalHeader.tsx | 45 ------------ .../__snapshots__/ModalHeader.spec.tsx.snap | 36 ---------- .../ModalWrapper/ModalWrapper.spec.tsx | 37 ---------- .../components/ModalWrapper/ModalWrapper.tsx | 40 ----------- .../__snapshots__/ModalWrapper.spec.tsx.snap | 36 ---------- .../components/Modal/components/index.ts | 24 ------- 11 files changed, 11 insertions(+), 454 deletions(-) delete mode 100644 packages/circuit-ui/components/Modal/components/ModalFooter/ModalFooter.spec.tsx delete mode 100644 packages/circuit-ui/components/Modal/components/ModalFooter/ModalFooter.tsx delete mode 100644 packages/circuit-ui/components/Modal/components/ModalFooter/__snapshots__/ModalFooter.spec.tsx.snap delete mode 100644 packages/circuit-ui/components/Modal/components/ModalHeader/ModalHeader.spec.tsx delete mode 100644 packages/circuit-ui/components/Modal/components/ModalHeader/ModalHeader.tsx delete mode 100644 packages/circuit-ui/components/Modal/components/ModalHeader/__snapshots__/ModalHeader.spec.tsx.snap delete mode 100644 packages/circuit-ui/components/Modal/components/ModalWrapper/ModalWrapper.spec.tsx delete mode 100644 packages/circuit-ui/components/Modal/components/ModalWrapper/ModalWrapper.tsx delete mode 100644 packages/circuit-ui/components/Modal/components/ModalWrapper/__snapshots__/ModalWrapper.spec.tsx.snap delete mode 100644 packages/circuit-ui/components/Modal/components/index.ts diff --git a/packages/circuit-ui/components/Modal/Modal.docs.mdx b/packages/circuit-ui/components/Modal/Modal.docs.mdx index c68104813c..02e9e724b1 100644 --- a/packages/circuit-ui/components/Modal/Modal.docs.mdx +++ b/packages/circuit-ui/components/Modal/Modal.docs.mdx @@ -1,5 +1,4 @@ import { Status, Props, Story } from '../../../../.storybook/components'; -import { ModalWrapper, ModalHeader, ModalFooter } from '.'; # Modal @@ -8,9 +7,6 @@ import { ModalWrapper, ModalHeader, ModalFooter } from '.'; 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. - - - ## When to use it @@ -18,74 +14,32 @@ Use it when you want the user to focus on a single and perhaps more complex task ## Usage guidelines -#### 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). -#### Header guidelines - -- **Do** use concise yet descriptive headings that label the function of the specific modal. -- **Do not** exclude headings from modals. - -#### 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. - ## 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 - 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'; +import { useModal, ModalProvider, Button, Body } from '@sumup/circuit-ui'; const SayHello = ({ name }) => { const { setModal } = useModal(); - const showModal = () => { - setModal({ - children: ({ onClose }) => ( - - - Hello {name} - - ), - }); + + const handleClick = () => { + setModal({ children: Hello {name} }); }; - return ; -}; -const Page = () => { - return ( - - - - ); + return ; }; -``` -- `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. +const Page = () => ( + + + +); +``` 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 ); - } -} - -