diff --git a/packages/thumbprint-react/CHANGELOG.md b/packages/thumbprint-react/CHANGELOG.md index 7b551758a..b80cf7b15 100644 --- a/packages/thumbprint-react/CHANGELOG.md +++ b/packages/thumbprint-react/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +### Added + +- [Patch] Create new hook for registering an ESC listener. +- [Patch] Create new hook for trapping focus. +- [Patch] Create new utility component for conditionally using portals. + +### Changed + +- [Patch] Refactor ModalCurtain to be a function component and use hooks. +- [Patch] Refactor Tooltip to use the new hooks. +- [Patch] Refactor Popover to use the new hooks. + ### Fixed - [Patch] Add `muted` theme to tertiary `Button` loading state to match the button's text color. (#352) diff --git a/packages/thumbprint-react/components/ModalCurtain/__snapshots__/test.jsx.snap b/packages/thumbprint-react/components/ModalCurtain/__snapshots__/test.jsx.snap index 264985e85..3e39c32c9 100644 --- a/packages/thumbprint-react/components/ModalCurtain/__snapshots__/test.jsx.snap +++ b/packages/thumbprint-react/components/ModalCurtain/__snapshots__/test.jsx.snap @@ -1,334 +1,228 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`renders \`accessibilityLabel\` 1`] = ` +exports[`ModalCurtain renders \`accessibilityLabel\` 1`] = ` - -
-
-
-
-
-
- - } + - +
+
+
+
+
+
+ } - paused={false} >
- - + + `; -exports[`renders a basic curtain 1`] = ` +exports[`ModalCurtain renders a basic curtain 1`] = ` - -
- - } + - +
+ } - paused={false} >
- - + + `; -exports[`renders modal in \`entered\` stage 1`] = ` +exports[`ModalCurtain renders modal in \`entered\` stage 1`] = ` - -
-
-
-
- - } + - +
+
+
+
+ } - paused={false} >
-
- - + + `; -exports[`renders modal in \`entering\` stage 1`] = ` +exports[`ModalCurtain renders modal in \`entering\` stage 1`] = ` - -
-
-
- - } + - +
+
+
+ } - paused={false} >
-
- - + + `; -exports[`renders modal in \`exited\` stage 1`] = ` +exports[`ModalCurtain renders modal in \`exited\` stage 1`] = ` - -
-
- - } + - +
+
+ } - paused={false} >
- - + + `; -exports[`renders modal in \`exiting\` stage 1`] = ` +exports[`ModalCurtain renders modal in \`exiting\` stage 1`] = ` - -
-
-
-
-
- - } - > - -
- - - -`; - -exports[`renders text that is passed in 1`] = ` -
-
-
- Goose -
} > - + + + +`; + +exports[`ModalCurtain renders text that is passed in 1`] = ` +
+
+
+ Goose +
+ } - paused={false} >
Goose
-
- + +
`; diff --git a/packages/thumbprint-react/components/ModalCurtain/components/esc-listener.jsx b/packages/thumbprint-react/components/ModalCurtain/components/esc-listener.jsx deleted file mode 100644 index fa9fa3abb..000000000 --- a/packages/thumbprint-react/components/ModalCurtain/components/esc-listener.jsx +++ /dev/null @@ -1,37 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; - -const ESC_KEY = 27; - -export default class EscListener extends React.Component { - constructor(props) { - super(props); - - this.handleKeyDown = this.handleKeyDown.bind(this); - } - - componentDidMount() { - document.addEventListener('keydown', this.handleKeyDown); - } - - componentWillUnmount() { - document.removeEventListener('keydown', this.handleKeyDown); - } - - handleKeyDown(event) { - const { onEscPress } = this.props; - - if (event.keyCode === ESC_KEY) { - event.preventDefault(); - onEscPress(event); - } - } - - render() { - return null; - } -} - -EscListener.propTypes = { - onEscPress: PropTypes.func.isRequired, -}; diff --git a/packages/thumbprint-react/components/ModalCurtain/index.jsx b/packages/thumbprint-react/components/ModalCurtain/index.jsx index 178c885da..ede903303 100644 --- a/packages/thumbprint-react/components/ModalCurtain/index.jsx +++ b/packages/thumbprint-react/components/ModalCurtain/index.jsx @@ -1,10 +1,13 @@ -import React from 'react'; -import ReactDOM from 'react-dom'; +import React, { useState, useEffect } from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; -import FocusTrap from 'focus-trap-react'; + import NoScroll from './components/no-scroll.jsx'; -import EscListener from './components/esc-listener.jsx'; + +import useCloseOnEscape from '../../utils/use-close-on-escape'; +import useFocusTrap from '../../utils/use-focus-trap'; +import ConditionalPortal from '../../utils/ConditionalPortal.jsx'; + import styles from './index.module.scss'; const propTypes = { @@ -54,89 +57,69 @@ const defaultProps = { shouldCloseOnEscape: true, }; -export default class ModalCurtain extends React.Component { - constructor(props) { - super(props); - - this.state = { - isClient: false, - }; - - this.modalWrapperNode = null; - } - - componentDidMount() { - this.setState({ isClient: true }); - } - - render() { - const { isClient } = this.state; - const { shouldCloseOnEscape, accessibilityLabel, onCloseClick, children } = this.props; - - if (!isClient) return null; - - const { stage } = this.props; - - const isEnteringOrEntered = stage === 'entering' || stage === 'entered'; - const shouldRenderEscListener = shouldCloseOnEscape && isEnteringOrEntered; - const shouldRenderNoScroll = isEnteringOrEntered; - - return ReactDOM.createPortal( - this.modalWrapperNode, +export default function ModalCurtain({ + stage, + shouldCloseOnEscape, + accessibilityLabel, + onCloseClick, + children, +}) { + const [isClient, setIsClient] = useState(false); + const [wrapperEl, setWrapperEl] = useState(null); + + useEffect(() => { + setIsClient(true); + }, []); + + const isEnteringOrEntered = stage === 'entering' || stage === 'entered'; + const shouldBindEscListener = isClient && shouldCloseOnEscape && isEnteringOrEntered; + const shouldTrapFocus = isClient && wrapperEl && stage === 'entered'; + const shouldDisableScrolling = isEnteringOrEntered; + + useCloseOnEscape(onCloseClick, shouldBindEscListener); + useFocusTrap(wrapperEl, shouldTrapFocus, { + clickOutsideDeactivates: true, + // Set initial focus to the modal wrapper itself instead of focusing on the first + // focusable element by default + initialFocus: wrapperEl, + }); + + return ( + + {/* Use tabIndex="-1" to allow programmatic focus (as initialFocus node for focus-trap) + but not be tabbable by user. */} +
{ + setWrapperEl(element); }} > - {/** - Use tabIndex="-1" to allow programmatic focus (as initialFocus node for ) - but not be tabbable by user. - */} -
{ - this.modalWrapperNode = div; - }} - > - {shouldRenderEscListener && } - - {shouldRenderNoScroll && } - - {/** - This component uses the render prop pattern. `children` expects a function - and receives an object that contains `curtainOnClick` and - `curtainClassName`. - - While using those two properties is optional, they provide helpful - functionality. - */} - {children && - children({ - curtainOnClick: event => { - // Ensures that the click event happened on the element that has the - // `onClick`. This prevents clicks deep within `children` from bubbling - // up and closing the ModalCurtain. - if (event.target === event.currentTarget) { - onCloseClick(); - } - }, - curtainClassName: classNames({ - [styles.root]: true, - [styles.rootOpen]: isEnteringOrEntered, - }), - })} -
-
, - document.body, - ); - } + {shouldDisableScrolling && } + + {/* This component uses the render prop pattern. `children` expects a function and + receives an object that contains `curtainOnClick` and `curtainClassName`. + While using those two properties is optional, they provide helpful functionality. */} + {children && + children({ + curtainOnClick: event => { + // Ensures that the click event happened on the element that has the + // `onClick`. This prevents clicks deep within `children` from bubbling + // up and closing the ModalCurtain. + if (event.target === event.currentTarget) { + onCloseClick(); + } + }, + curtainClassName: classNames({ + [styles.root]: true, + [styles.rootOpen]: isEnteringOrEntered, + }), + })} +
+
+ ); } ModalCurtain.propTypes = propTypes; - ModalCurtain.defaultProps = defaultProps; diff --git a/packages/thumbprint-react/components/ModalCurtain/test.jsx b/packages/thumbprint-react/components/ModalCurtain/test.jsx index f0fdcbeac..b14f232e7 100644 --- a/packages/thumbprint-react/components/ModalCurtain/test.jsx +++ b/packages/thumbprint-react/components/ModalCurtain/test.jsx @@ -8,277 +8,283 @@ const BACKSPACE_KEY = 8; const SPACE_KEY = 32; const LOWERCASE_A_KEY = 65; -test('renders a basic curtain', () => { - const wrapper = mount(); - expect(wrapper).toMatchSnapshot(); -}); - -test('renders modal in `exited` stage', () => { - const wrapper = mount(); - expect(wrapper).toMatchSnapshot(); -}); - -test('renders modal in `entering` stage', () => { - const wrapper = mount(); - expect(wrapper).toMatchSnapshot(); -}); - -test('renders modal in `entered` stage', () => { - const wrapper = mount(); - expect(wrapper).toMatchSnapshot(); -}); - -test('renders modal in `exiting` stage', () => { - const wrapper = mount(); - expect(wrapper).toMatchSnapshot(); -}); +describe('ModalCurtain', () => { + test('renders a basic curtain', () => { + const wrapper = mount(); + expect(wrapper).toMatchSnapshot(); + }); -test('renders `accessibilityLabel`', () => { - const wrapper = mount( - , - ); - expect(wrapper.html()).toContain('goosegoosegoose'); - expect(wrapper).toMatchSnapshot(); -}); + test('renders modal in `exited` stage', () => { + const wrapper = mount(); + expect(wrapper).toMatchSnapshot(); + }); -test('`children` receives `curtainOnClick` and `curtainClassName`', () => { - let obj; + test('renders modal in `entering` stage', () => { + const wrapper = mount(); + expect(wrapper).toMatchSnapshot(); + }); - mount( - - {args => { - obj = args; - return null; - }} - , - ); + test('renders modal in `entered` stage', () => { + const wrapper = mount(); + expect(wrapper).toMatchSnapshot(); + }); - expect(obj.curtainOnClick).toBeTruthy(); - expect(obj.curtainClassName).toBeTruthy(); -}); + test('renders modal in `exiting` stage', () => { + const wrapper = mount(); + expect(wrapper).toMatchSnapshot(); + }); -test("`children`'s `curtainOnClick` render prop calls `onCloseClick` prop when executed", () => { - const onClick = jest.fn(); + test('renders `accessibilityLabel`', () => { + const wrapper = mount( + , + ); + expect(wrapper.html()).toContain('goosegoosegoose'); + expect(wrapper).toMatchSnapshot(); + }); - const wrapper = mount( - - {({ curtainOnClick }) => ( -
, - ); + const wrapper = mount( + + {({ curtainOnClick }) => ( +
, + ); - const modalWrapper = wrapper.find('[role="dialog"]'); - expect(modalWrapper.is(':focus')).toBe(true); + expect(wrapper.find('ModalCurtain').text()).toEqual('Goose'); + expect(wrapper).toMatchSnapshot(); + }); - jest.useRealTimers(); -}); + test('initially traps focus to the root dialog node', () => { + jest.useFakeTimers(); -test('`onCloseClick` is not called when non-ESC keys are pressed', () => { - const onCloseClick = jest.fn(); - mount(); + const onCloseClick = jest.fn(); + const wrapper = mount( + + {() => ( + <> + + + + + + +
-
- - } - > - } - paused={false} >
- - + + @@ -343,81 +327,73 @@ exports[`ModalDefault renders a basic modal with content 1`] = ` shouldCloseOnEscape={true} stage="exited" > - -
+ + - - } - > - } - paused={false} >
- -
+ + @@ -566,72 +542,64 @@ exports[`ModalDefault renders a narrow modal 1`] = ` shouldCloseOnEscape={true} stage="exited" > - -
+ + - - } - > - } - paused={false} >
- -
+ + @@ -771,72 +739,64 @@ exports[`ModalDefault renders a wide modal 1`] = ` shouldCloseOnEscape={true} stage="exited" > - -
+ + - - } - > - } - paused={false} >
- -
+ + @@ -976,72 +936,64 @@ exports[`ModalDefault shows the close button if \`shouldHideCloseButton\` is fal shouldCloseOnEscape={true} stage="exited" > - -
+ + - - } - > - } - paused={false} >
- -
+ + @@ -1213,78 +1165,70 @@ exports[`ModalDefaultFooter renders a sticky footer 1`] = ` shouldCloseOnEscape={true} stage="exited" > - -
+ + - - } - > - } - paused={false} >
- -
+ + diff --git a/packages/thumbprint-react/components/Popover/__snapshots__/test.jsx.snap b/packages/thumbprint-react/components/Popover/__snapshots__/test.jsx.snap index 7193bc859..ceae0203d 100644 --- a/packages/thumbprint-react/components/Popover/__snapshots__/test.jsx.snap +++ b/packages/thumbprint-react/components/Popover/__snapshots__/test.jsx.snap @@ -15,16 +15,19 @@ exports[`renders a popover 1`] = ` getReferenceRef={[Function]} /> - -