diff --git a/app/javascript/packages/document-capture/components/full-screen.scss b/app/assets/stylesheets/components/_full-screen.scss similarity index 100% rename from app/javascript/packages/document-capture/components/full-screen.scss rename to app/assets/stylesheets/components/_full-screen.scss diff --git a/app/assets/stylesheets/components/all.scss b/app/assets/stylesheets/components/all.scss index 7d950bc21e4..1a0947e5b58 100644 --- a/app/assets/stylesheets/components/all.scss +++ b/app/assets/stylesheets/components/all.scss @@ -8,6 +8,7 @@ @import 'form-steps'; @import 'footer'; @import 'form'; +@import 'full-screen'; @import 'hr'; @import 'icon'; @import 'list'; diff --git a/spec/javascripts/packages/document-capture/components/full-screen-spec.jsx b/app/javascript/packages/components/full-screen.spec.tsx similarity index 73% rename from spec/javascripts/packages/document-capture/components/full-screen-spec.jsx rename to app/javascript/packages/components/full-screen.spec.tsx index 0370ffd00c2..be3cfa59882 100644 --- a/spec/javascripts/packages/document-capture/components/full-screen-spec.jsx +++ b/app/javascript/packages/components/full-screen.spec.tsx @@ -3,13 +3,12 @@ import { screen } from '@testing-library/dom'; import { render, fireEvent } from '@testing-library/react'; import { renderHook } from '@testing-library/react-hooks'; import sinon from 'sinon'; -import FullScreen, { - useInertSiblingElements, -} from '@18f/identity-document-capture/components/full-screen'; +import FullScreen, { useInertSiblingElements } from './full-screen'; +import type { FullScreenRefHandle } from './full-screen'; const delay = () => new Promise((resolve) => setTimeout(resolve, 0)); -describe('document-capture/components/full-screen', () => { +describe('FullScreen', () => { describe('useInertSiblingElements', () => { beforeEach(() => { document.body.innerHTML = ` @@ -55,6 +54,66 @@ describe('document-capture/components/full-screen', () => { expect(button.nodeName).to.equal('BUTTON'); }); + context('hideCloseButton prop is true', () => { + it('renders without a close button', () => { + const { queryByLabelText } = render( + + + , + ); + + const button = queryByLabelText('users.personal_key.close'); + + expect(button).to.not.exist(); + }); + }); + + it('renders with white background', () => { + const { baseElement } = render(Content); + + expect(baseElement.querySelector('.full-screen.bg-white')).to.exist(); + }); + + context('with bgColor prop', () => { + it('renders without a close button', () => { + const { baseElement } = render(Content); + + expect(baseElement.querySelector('.full-screen.bg-none')).to.exist(); + }); + }); + + it('applies label to dialog', () => { + render(Content); + + expect(screen.getByRole('dialog', { name: 'Modal' })).to.exist(); + }); + + context('with labelledBy prop', () => { + it('applies associates dialog with label', () => { + render( + + Modal + , + ); + + expect(screen.getByRole('dialog', { name: 'Modal' })).to.exist(); + }); + }); + + context('with describedBy prop', () => { + it('applies associates dialog with label', () => { + render( + + Description + , + ); + + const dialog = screen.getByRole('dialog'); + + expect(dialog.getAttribute('aria-describedby')).to.equal('custom-description'); + }); + }); + it('focuses the first interactive element', async () => { const { getByRole } = render( @@ -68,7 +127,7 @@ describe('document-capture/components/full-screen', () => { }); it('focuses the close button as a fallback', async () => { - const { getByRole } = render(); + const { getByRole } = render(Content); await delay(); // focus-trap delays initial focus by default expect(document.activeElement).to.equal( @@ -166,7 +225,7 @@ describe('document-capture/components/full-screen', () => { }); it('only removes body class when last mounted modal is removed', () => { - const { rerender } = render( + const { rerender, unmount } = render( <> Please don’t do this. @@ -177,15 +236,15 @@ describe('document-capture/components/full-screen', () => { expect(document.body.classList.contains('has-full-screen-overlay')).to.be.true(); - rerender(null); + unmount(); expect(document.body.classList.contains('has-full-screen-overlay')).to.be.false(); }); it('exposes focus trap on its ref', () => { - const ref = createRef(); + const ref = createRef(); render(Content); - expect(ref.current.focusTrap.deactivate).to.be.a('function'); + expect(ref.current!.focusTrap!.deactivate).to.be.a('function'); }); }); diff --git a/app/javascript/packages/components/full-screen.tsx b/app/javascript/packages/components/full-screen.tsx new file mode 100644 index 00000000000..510c8365394 --- /dev/null +++ b/app/javascript/packages/components/full-screen.tsx @@ -0,0 +1,130 @@ +import { forwardRef, useImperativeHandle, useRef, useEffect } from 'react'; +import type { ReactNode, ForwardedRef, MutableRefObject } from 'react'; +import { createPortal } from 'react-dom'; +import type { FocusTrap } from 'focus-trap'; +import { useI18n } from '@18f/identity-react-i18n'; +import { useIfStillMounted, useImmutableCallback } from '@18f/identity-react-hooks'; +import { getAssetPath } from '@18f/identity-assets'; +import useToggleBodyClassByPresence from './hooks/use-toggle-body-class-by-presence'; +import useFocusTrap from './hooks/use-focus-trap'; + +type BackgroundColor = 'white' | 'none'; + +interface FullScreenProps { + /** + * Callback invoked when user initiates close intent. + */ + onRequestClose?: () => void; + + /** + * Accessible label for modal. + */ + label?: string; + + /** + * Whether to omit default close button, in case it is implemented by full screen content. + */ + hideCloseButton?: boolean; + + /** + * Background color of full-screen dialog. Defaults to "white". + */ + bgColor?: BackgroundColor; + + /** + * Identifier of element(s) which label the modal. + */ + labelledBy?: string; + + /** + * Identifier of element(s) which describe the modal. + */ + describedBy?: string; + + /** + * Child elements. + */ + children: ReactNode; +} + +export interface FullScreenRefHandle { + focusTrap: FocusTrap | null; +} + +export function useInertSiblingElements(containerRef: MutableRefObject) { + useEffect(() => { + const container = containerRef.current; + + const originalElementAttributeValues: [Element, string | null][] = []; + if (container && container.parentNode) { + for (const child of container.parentNode.children) { + if (child !== container) { + originalElementAttributeValues.push([child, child.getAttribute('aria-hidden')]); + child.setAttribute('aria-hidden', 'true'); + } + } + } + + return () => + originalElementAttributeValues.forEach(([child, ariaHidden]) => + ariaHidden === null + ? child.removeAttribute('aria-hidden') + : child.setAttribute('aria-hidden', ariaHidden), + ); + }); +} + +function FullScreen( + { + onRequestClose = () => {}, + label, + hideCloseButton = false, + bgColor = 'white', + labelledBy, + describedBy, + children, + }: FullScreenProps, + ref: ForwardedRef, +) { + const { t } = useI18n(); + const ifStillMounted = useIfStillMounted(); + const containerRef = useRef(null as HTMLDivElement | null); + const onFocusTrapDeactivate = useImmutableCallback(ifStillMounted(onRequestClose)); + const focusTrap = useFocusTrap(containerRef, { + clickOutsideDeactivates: true, + onDeactivate: onFocusTrapDeactivate, + }); + useImperativeHandle(ref, () => ({ focusTrap }), [focusTrap]); + useToggleBodyClassByPresence('has-full-screen-overlay', FullScreen); + useInertSiblingElements(containerRef); + + return createPortal( +
+ {children} + {!hideCloseButton && ( + + )} +
, + document.body, + ); +} + +export default forwardRef(FullScreen); diff --git a/spec/javascripts/packages/document-capture/hooks/use-focus-trap-spec.jsx b/app/javascript/packages/components/hooks/use-focus-trap.spec.ts similarity index 81% rename from spec/javascripts/packages/document-capture/hooks/use-focus-trap-spec.jsx rename to app/javascript/packages/components/hooks/use-focus-trap.spec.ts index 8374820d9ab..f3fee4e0ecd 100644 --- a/spec/javascripts/packages/document-capture/hooks/use-focus-trap-spec.jsx +++ b/app/javascript/packages/components/hooks/use-focus-trap.spec.ts @@ -3,11 +3,11 @@ import { useRef } from 'react'; import { screen } from '@testing-library/dom'; import userEvent from '@testing-library/user-event'; import { renderHook } from '@testing-library/react-hooks'; -import useFocusTrap from '@18f/identity-document-capture/hooks/use-focus-trap'; +import useFocusTrap from './use-focus-trap'; const delay = () => new Promise((resolve) => setTimeout(resolve, 0)); -describe('document-capture/hooks/use-focus-trap', () => { +describe('useFocusTrap', () => { // Common options for test instances. Default delayed initial focus adds complexity to assertions. const DEFAULT_OPTIONS = { delayInitialFocus: false }; @@ -22,15 +22,15 @@ describe('document-capture/hooks/use-focus-trap', () => { }); it('returns focus trap', () => { - const container = document.querySelector('.container'); + const container = document.querySelector('.container') as HTMLElement; const { result } = renderHook(() => useFocusTrap(useRef(container), DEFAULT_OPTIONS)); - const trap = result.current; + const trap = result.current!; expect(trap.deactivate).to.be.a('function'); }); it('traps focus', () => { - const container = document.querySelector('.container'); + const container = document.querySelector('.container') as HTMLElement; renderHook(() => useFocusTrap(useRef(container), DEFAULT_OPTIONS)); expect(container.contains(document.activeElement)).to.be.true(); @@ -40,10 +40,10 @@ describe('document-capture/hooks/use-focus-trap', () => { it('restores focus on deactivate', async () => { const originalActiveElement = document.activeElement; - const container = document.querySelector('.container'); + const container = document.querySelector('.container') as HTMLElement; const { result } = renderHook(() => useFocusTrap(useRef(container), DEFAULT_OPTIONS)); - const trap = result.current; + const trap = result.current!; trap.deactivate(); // Delay for focus return isn't configurable. @@ -53,7 +53,7 @@ describe('document-capture/hooks/use-focus-trap', () => { }); it('accepts options', () => { - const container = document.querySelector('.container'); + const container = document.querySelector('.container') as HTMLElement; const onDeactivate = sinon.spy(); renderHook(() => useFocusTrap(useRef(container), { diff --git a/app/javascript/packages/document-capture/hooks/use-focus-trap.js b/app/javascript/packages/components/hooks/use-focus-trap.ts similarity index 68% rename from app/javascript/packages/document-capture/hooks/use-focus-trap.js rename to app/javascript/packages/components/hooks/use-focus-trap.ts index 73fe717bd39..c06a68e28a5 100644 --- a/app/javascript/packages/document-capture/hooks/use-focus-trap.js +++ b/app/javascript/packages/components/hooks/use-focus-trap.ts @@ -1,21 +1,16 @@ import { useState, useEffect } from 'react'; +import type { MutableRefObject } from 'react'; import { createFocusTrap } from 'focus-trap'; - -/** @typedef {import('focus-trap').FocusTrap} FocusTrap */ +import type { FocusTrap, Options } from 'focus-trap'; /** * React hook which activates a focus trap on the given container ref while the component is * mounted, with any options for the underlying focus trap instance. The hook does not detect * changes to the options argument, thus new option values are not reflected and conversely * memoization is not necessary. Returns ref with trap instance assigned as current after mount. - * - * @param {React.MutableRefObject} containerRef - * @param {import('focus-trap').Options=} options - * - * @param {React.MutableRefObject} containerRef */ -function useFocusTrap(containerRef, options) { - const [trap, setTrap] = useState(/** @type {FocusTrap?} */ (null)); +function useFocusTrap(containerRef: MutableRefObject, options?: Options) { + const [trap, setTrap] = useState(null as FocusTrap | null); useEffect(() => { let focusTrap; diff --git a/spec/javascripts/packages/document-capture/hooks/use-toggle-body-class-by-presence-spec.jsx b/app/javascript/packages/components/hooks/use-toggle-body-class-by-presence.spec.ts similarity index 87% rename from spec/javascripts/packages/document-capture/hooks/use-toggle-body-class-by-presence-spec.jsx rename to app/javascript/packages/components/hooks/use-toggle-body-class-by-presence.spec.ts index f0b18fecc71..c14edf0e28b 100644 --- a/spec/javascripts/packages/document-capture/hooks/use-toggle-body-class-by-presence-spec.jsx +++ b/app/javascript/packages/components/hooks/use-toggle-body-class-by-presence.spec.ts @@ -1,9 +1,11 @@ +/* eslint-disable react/function-component-definition */ + import { renderHook, cleanup } from '@testing-library/react-hooks'; -import useToggleBodyClassByPresence from '@18f/identity-document-capture/hooks/use-toggle-body-class-by-presence'; +import useToggleBodyClassByPresence from './use-toggle-body-class-by-presence'; -describe('document-capture/hooks/use-toggle-body-class-by-presence', () => { - function ComponentOne() {} - function ComponentTwo() {} +describe('useToggleBodyClassByPresence', () => { + const ComponentOne = () => null; + const ComponentTwo = () => null; afterEach(cleanup); diff --git a/app/javascript/packages/document-capture/hooks/use-toggle-body-class-by-presence.js b/app/javascript/packages/components/hooks/use-toggle-body-class-by-presence.ts similarity index 72% rename from app/javascript/packages/document-capture/hooks/use-toggle-body-class-by-presence.js rename to app/javascript/packages/components/hooks/use-toggle-body-class-by-presence.ts index 5d4bc2520a4..f5f0029a46e 100644 --- a/app/javascript/packages/document-capture/hooks/use-toggle-body-class-by-presence.js +++ b/app/javascript/packages/components/hooks/use-toggle-body-class-by-presence.ts @@ -1,25 +1,21 @@ import { useEffect } from 'react'; +import type { ComponentType } from 'react'; -/** - * @type {WeakMap<*, number>} - */ -const activeInstancesByType = new WeakMap(); +const activeInstancesByType = new WeakMap(); /** * React hook to add a CSS class to the page body element as long as any instance of the given * component type is rendered to the page. * - * @param {string} className Class name to add to body element - * @param {React.ComponentType} Component React component definition + * @param className Class name to add to body element + * @param Component React component definition */ -function useToggleBodyClassByPresence(className, Component) { +function useToggleBodyClassByPresence(className: string, Component: ComponentType) { /** * Increments the number of active instances for the current component by the given amount, adding * or removing the body class for the first and last instance respectively. - * - * @param {number} amount */ - function incrementActiveInstances(amount) { + function incrementActiveInstances(amount: number) { const activeInstances = activeInstancesByType.get(Component) || 0; const nextActiveInstances = activeInstances + amount; diff --git a/app/javascript/packages/components/index.ts b/app/javascript/packages/components/index.ts index 8d848350dc3..37a884ddf4c 100644 --- a/app/javascript/packages/components/index.ts +++ b/app/javascript/packages/components/index.ts @@ -2,8 +2,10 @@ export { default as Alert } from './alert'; export { default as Button } from './button'; export { default as BlockLink } from './block-link'; export { default as Icon } from './icon'; +export { default as FullScreen } from './full-screen'; export { default as PageHeading } from './page-heading'; export { default as SpinnerDots } from './spinner-dots'; export { default as TroubleshootingOptions } from './troubleshooting-options'; export type { ButtonProps } from './button'; +export type { FullScreenRefHandle } from './full-screen'; diff --git a/app/javascript/packages/components/package.json b/app/javascript/packages/components/package.json index 400ac567672..cf5d703e535 100644 --- a/app/javascript/packages/components/package.json +++ b/app/javascript/packages/components/package.json @@ -3,6 +3,8 @@ "private": true, "version": "1.0.0", "dependencies": { - "react": "^17.0.2" + "focus-trap": "^6.7.1", + "react": "^17.0.2", + "react-dom": "^17.0.2" } } diff --git a/app/javascript/packages/document-capture/components/acuant-camera.jsx b/app/javascript/packages/document-capture/components/acuant-camera.jsx index 53102e5995a..32d04c8ef61 100644 --- a/app/javascript/packages/document-capture/components/acuant-camera.jsx +++ b/app/javascript/packages/document-capture/components/acuant-camera.jsx @@ -1,6 +1,6 @@ import { useContext, useEffect } from 'react'; import { useI18n } from '@18f/identity-react-i18n'; -import useImmutableCallback from '../hooks/use-immutable-callback'; +import { useImmutableCallback } from '@18f/identity-react-hooks'; import AcuantContext from '../context/acuant'; /** @typedef {import('../context/acuant').AcuantJavaScriptWebSDK} AcuantJavaScriptWebSDK */ diff --git a/app/javascript/packages/document-capture/components/acuant-capture.jsx b/app/javascript/packages/document-capture/components/acuant-capture.jsx index 1760593197b..65a3ed57523 100644 --- a/app/javascript/packages/document-capture/components/acuant-capture.jsx +++ b/app/javascript/packages/document-capture/components/acuant-capture.jsx @@ -9,14 +9,13 @@ import { } from 'react'; import { useI18n } from '@18f/identity-react-i18n'; import { useIfStillMounted, useDidUpdateEffect } from '@18f/identity-react-hooks'; -import { Button } from '@18f/identity-components'; +import { Button, FullScreen } from '@18f/identity-components'; import AnalyticsContext from '../context/analytics'; import AcuantContext from '../context/acuant'; import FailedCaptureAttemptsContext from '../context/failed-capture-attempts'; import AcuantCamera from './acuant-camera'; import AcuantCaptureCanvas from './acuant-capture-canvas'; import FileInput from './file-input'; -import FullScreen from './full-screen'; import DeviceContext from '../context/device'; import UploadContext from '../context/upload'; import useCounter from '../hooks/use-counter'; @@ -25,7 +24,7 @@ import useCookie from '../hooks/use-cookie'; /** @typedef {import('react').ReactNode} ReactNode */ /** @typedef {import('./acuant-camera').AcuantSuccessResponse} AcuantSuccessResponse */ /** @typedef {import('./acuant-camera').AcuantDocumentType} AcuantDocumentType */ -/** @typedef {import('./full-screen').FullScreenRefHandle} FullScreenRefHandle */ +/** @typedef {import('@18f/identity-components').FullScreenRefHandle} FullScreenRefHandle */ /** @typedef {import('../context/acuant').AcuantGlobal} AcuantGlobal */ /** diff --git a/app/javascript/packages/document-capture/components/full-screen.jsx b/app/javascript/packages/document-capture/components/full-screen.jsx deleted file mode 100644 index 9a4311f8308..00000000000 --- a/app/javascript/packages/document-capture/components/full-screen.jsx +++ /dev/null @@ -1,87 +0,0 @@ -import { forwardRef, useImperativeHandle, useRef, useEffect } from 'react'; -import { createPortal } from 'react-dom'; -import { useI18n } from '@18f/identity-react-i18n'; -import { useIfStillMounted } from '@18f/identity-react-hooks'; -import { getAssetPath } from '@18f/identity-assets'; -import useToggleBodyClassByPresence from '../hooks/use-toggle-body-class-by-presence'; -import useImmutableCallback from '../hooks/use-immutable-callback'; -import useFocusTrap from '../hooks/use-focus-trap'; - -/** @typedef {import('focus-trap').FocusTrap} FocusTrap */ -/** @typedef {import('react').ReactNode} ReactNode */ - -/** - * @typedef FullScreenProps - * - * @prop {()=>void=} onRequestClose Callback invoked when user initiates close intent. - * @prop {string} label Accessible label for modal. - * @prop {ReactNode} children Child elements. - */ - -/** - * @typedef {{focusTrap: import('focus-trap').FocusTrap?}} FullScreenRefHandle - */ - -/** - * @param {React.MutableRefObject} containerRef - */ -export function useInertSiblingElements(containerRef) { - useEffect(() => { - const container = containerRef.current; - - /** - * @type {[Element, string|null][]} - */ - const originalElementAttributeValues = []; - if (container && container.parentNode) { - for (const child of container.parentNode.children) { - if (child !== container) { - originalElementAttributeValues.push([child, child.getAttribute('aria-hidden')]); - child.setAttribute('aria-hidden', 'true'); - } - } - } - - return () => - originalElementAttributeValues.forEach(([child, ariaHidden]) => - ariaHidden === null - ? child.removeAttribute('aria-hidden') - : child.setAttribute('aria-hidden', ariaHidden), - ); - }); -} - -/** - * @param {FullScreenProps} props Props object. - * @param {import('react').ForwardedRef} ref - */ -function FullScreen({ onRequestClose = () => {}, label, children }, ref) { - const { t } = useI18n(); - const ifStillMounted = useIfStillMounted(); - const containerRef = useRef(/** @type {HTMLDivElement?} */ (null)); - const onFocusTrapDeactivate = useImmutableCallback(ifStillMounted(onRequestClose)); - const focusTrap = useFocusTrap(containerRef, { - clickOutsideDeactivates: true, - onDeactivate: onFocusTrapDeactivate, - }); - useImperativeHandle(ref, () => ({ focusTrap }), [focusTrap]); - useToggleBodyClassByPresence('has-full-screen-overlay', FullScreen); - useInertSiblingElements(containerRef); - - return createPortal( -
- {children} - -
, - document.body, - ); -} - -export default forwardRef(FullScreen); diff --git a/app/javascript/packages/document-capture/package.json b/app/javascript/packages/document-capture/package.json index 5ca8225c207..5beb2ac765b 100644 --- a/app/javascript/packages/document-capture/package.json +++ b/app/javascript/packages/document-capture/package.json @@ -3,7 +3,6 @@ "private": true, "version": "1.0.0", "dependencies": { - "focus-trap": "^6.7.1", "react": "^17.0.2", "react-dom": "^17.0.2" } diff --git a/app/javascript/packages/document-capture/styles.scss b/app/javascript/packages/document-capture/styles.scss index 44dbbd25381..0e60677fa39 100644 --- a/app/javascript/packages/document-capture/styles.scss +++ b/app/javascript/packages/document-capture/styles.scss @@ -1,6 +1,5 @@ @import 'identity-style-guide/dist/assets/scss/packages/required'; @import './components/acuant-capture'; @import './components/acuant-capture-canvas'; -@import './components/full-screen'; @import './components/review-issues-step'; @import './components/selfie-capture'; diff --git a/app/javascript/packages/eslint-plugin/CHANGELOG.md b/app/javascript/packages/eslint-plugin/CHANGELOG.md index 8d51f19f55d..a9c91168d66 100644 --- a/app/javascript/packages/eslint-plugin/CHANGELOG.md +++ b/app/javascript/packages/eslint-plugin/CHANGELOG.md @@ -28,6 +28,7 @@ - `semi` - `semi-spacing` - `semi-style` + - `space-before-function-paren` - `space-in-parens` - `switch-colon-spacing` - `template-curly-spacing` diff --git a/app/javascript/packages/eslint-plugin/configs/recommended.js b/app/javascript/packages/eslint-plugin/configs/recommended.js index 72632922726..278236851bb 100644 --- a/app/javascript/packages/eslint-plugin/configs/recommended.js +++ b/app/javascript/packages/eslint-plugin/configs/recommended.js @@ -61,6 +61,7 @@ const config = { semi: 'off', 'semi-spacing': 'off', 'semi-style': 'off', + 'space-before-function-paren': 'off', 'space-in-parens': 'off', 'switch-colon-spacing': 'off', 'template-curly-spacing': 'off', diff --git a/app/javascript/packages/modal/README.md b/app/javascript/packages/modal/README.md index e234e033e86..035956efe97 100644 --- a/app/javascript/packages/modal/README.md +++ b/app/javascript/packages/modal/README.md @@ -22,7 +22,7 @@ import { Modal } from '@18f/identity-modal'; export function Example() { return ( - {}} onClose={() => {}}> + Are you sure you want to continue? You have unsaved changes that will be lost. diff --git a/app/javascript/packages/modal/modal.tsx b/app/javascript/packages/modal/modal.tsx index b31dc941b3f..a153696afc0 100644 --- a/app/javascript/packages/modal/modal.tsx +++ b/app/javascript/packages/modal/modal.tsx @@ -1,44 +1,62 @@ import { createContext, useContext } from 'react'; import type { ReactNode } from 'react'; +import { FullScreen } from '@18f/identity-components'; import { useInstanceId } from '@18f/identity-react-hooks'; const ModalContext = createContext(''); interface ModalProps { + /** + * Callback invoked in response to user interaction indicating a request to close the modal. + */ + onRequestClose?: () => void; + + /** + * Modal content. + */ children: ReactNode; } interface ModalHeadingProps { + /** + * Heading text. + */ children: ReactNode; } interface ModalDescriptionProps { + /** + * Description text. + */ children: ReactNode; } -function Modal({ children }: ModalProps) { +function Modal({ children, onRequestClose }: ModalProps) { const instanceId = useInstanceId(); return ( -
-
-
-
-
-
- {children} + +
+
+
+
+
+
+ {children} +
-
+ ); } diff --git a/app/javascript/packages/react-hooks/index.ts b/app/javascript/packages/react-hooks/index.ts index 94fc83bbfb1..10c1e14a8de 100644 --- a/app/javascript/packages/react-hooks/index.ts +++ b/app/javascript/packages/react-hooks/index.ts @@ -1,3 +1,4 @@ export { default as useDidUpdateEffect } from './use-did-update-effect'; export { default as useIfStillMounted } from './use-if-still-mounted'; +export { default as useImmutableCallback } from './use-immutable-callback'; export { default as useInstanceId } from './use-instance-id'; diff --git a/spec/javascripts/packages/document-capture/hooks/use-immutable-callback-spec.jsx b/app/javascript/packages/react-hooks/use-immutable-callback.spec.tsx similarity index 71% rename from spec/javascripts/packages/document-capture/hooks/use-immutable-callback-spec.jsx rename to app/javascript/packages/react-hooks/use-immutable-callback.spec.tsx index bbda26b59b0..6ec20b0b72b 100644 --- a/spec/javascripts/packages/document-capture/hooks/use-immutable-callback-spec.jsx +++ b/app/javascript/packages/react-hooks/use-immutable-callback.spec.tsx @@ -1,9 +1,9 @@ import sinon from 'sinon'; import { renderHook } from '@testing-library/react-hooks'; -import useImmutableCallback from '@18f/identity-document-capture/hooks/use-immutable-callback'; +import useImmutableCallback from './use-immutable-callback'; -describe('document-capture/hooks/use-immutable-callback', () => { - const callback1 = () => {}; +describe('useImmutableCallback', () => { + const callback1 = (_arg1: any, _arg2: any) => {}; const callback2 = sinon.stub().callsFake(() => {}); it('maintains a consistent reference', () => { @@ -24,9 +24,8 @@ describe('document-capture/hooks/use-immutable-callback', () => { }); rerender({ fn: callback2 }); - const args = [1, 2]; - result.current(...args); + result.current(1, 2); - expect(callback2).to.have.been.calledWith(...args); + expect(callback2).to.have.been.calledWith(1, 2); }); }); diff --git a/app/javascript/packages/document-capture/hooks/use-immutable-callback.js b/app/javascript/packages/react-hooks/use-immutable-callback.ts similarity index 57% rename from app/javascript/packages/document-capture/hooks/use-immutable-callback.js rename to app/javascript/packages/react-hooks/use-immutable-callback.ts index c1e7932b466..06fbb4ef3d4 100644 --- a/app/javascript/packages/document-capture/hooks/use-immutable-callback.js +++ b/app/javascript/packages/react-hooks/use-immutable-callback.ts @@ -6,21 +6,17 @@ import { useRef, useEffect, useCallback } from 'react'; * * @see https://reactjs.org/docs/hooks-faq.html#how-to-read-an-often-changing-value-from-usecallback * - * @template {(...args: any[]) => any} F - * - * @param {F} fn - * @param {any[]=} dependencies Callback dependencies - * - * @return {F} + * @param fn + * @param dependencies Callback dependencies */ -function useImmutableCallback(fn, dependencies = []) { - const ref = useRef(/** @type {F} */ (() => {})); +function useImmutableCallback any>(fn: F, dependencies: any[] = []) { + const ref = useRef((() => {}) as F); useEffect(() => { ref.current = fn; }, [fn, ...dependencies]); - return useCallback(/** @type {F} */ ((...args) => ref.current(...args)), [ref]); + return useCallback(((...args) => ref.current(...args)) as F, [ref]); } export default useImmutableCallback; diff --git a/app/javascript/packages/verify-flow/steps/personal-key-confirm/personal-key-confirm-step.spec.tsx b/app/javascript/packages/verify-flow/steps/personal-key-confirm/personal-key-confirm-step.spec.tsx index 4e2af8ac167..63d4cca1be1 100644 --- a/app/javascript/packages/verify-flow/steps/personal-key-confirm/personal-key-confirm-step.spec.tsx +++ b/app/javascript/packages/verify-flow/steps/personal-key-confirm/personal-key-confirm-step.spec.tsx @@ -13,7 +13,7 @@ describe('PersonalKeyConfirmStep', () => { registerField: () => () => {}, }; - it('allows the user to return to the previous step', () => { + it('allows the user to return to the previous step by clicking "Back" button', () => { const toPreviousStep = sinon.spy(); const { getByText } = render( , @@ -23,4 +23,15 @@ describe('PersonalKeyConfirmStep', () => { expect(toPreviousStep).to.have.been.called(); }); + + it('allows the user to return to the previous step by pressing Escape', () => { + const toPreviousStep = sinon.spy(); + const { getByRole } = render( + , + ); + + userEvent.type(getByRole('textbox'), '{esc}'); + + expect(toPreviousStep).to.have.been.called(); + }); }); diff --git a/app/javascript/packages/verify-flow/steps/personal-key-confirm/personal-key-confirm-step.tsx b/app/javascript/packages/verify-flow/steps/personal-key-confirm/personal-key-confirm-step.tsx index 8dc1b2d4aae..f9f5f76d6a8 100644 --- a/app/javascript/packages/verify-flow/steps/personal-key-confirm/personal-key-confirm-step.tsx +++ b/app/javascript/packages/verify-flow/steps/personal-key-confirm/personal-key-confirm-step.tsx @@ -18,7 +18,7 @@ function PersonalKeyConfirmStep(stepProps: PersonalKeyConfirmStepProps) { - +