Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/assets/stylesheets/components/all.scss
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
@import 'form-steps';
@import 'footer';
@import 'form';
@import 'full-screen';
@import 'hr';
@import 'icon';
@import 'list';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `
Expand Down Expand Up @@ -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(
<FullScreen hideCloseButton>
<input />
</FullScreen>,
);

const button = queryByLabelText('users.personal_key.close');

expect(button).to.not.exist();
});
});

it('renders with white background', () => {
const { baseElement } = render(<FullScreen>Content</FullScreen>);

expect(baseElement.querySelector('.full-screen.bg-white')).to.exist();
});

context('with bgColor prop', () => {
it('renders without a close button', () => {
const { baseElement } = render(<FullScreen bgColor="none">Content</FullScreen>);

expect(baseElement.querySelector('.full-screen.bg-none')).to.exist();
});
});

it('applies label to dialog', () => {
render(<FullScreen label="Modal">Content</FullScreen>);

expect(screen.getByRole('dialog', { name: 'Modal' })).to.exist();
});

context('with labelledBy prop', () => {
it('applies associates dialog with label', () => {
render(
<FullScreen labelledBy="custom-label">
<span id="custom-label">Modal</span>
</FullScreen>,
);

expect(screen.getByRole('dialog', { name: 'Modal' })).to.exist();
});
});

context('with describedBy prop', () => {
it('applies associates dialog with label', () => {
render(
<FullScreen describedBy="custom-description">
<span id="custom-description">Description</span>
</FullScreen>,
);

const dialog = screen.getByRole('dialog');

expect(dialog.getAttribute('aria-describedby')).to.equal('custom-description');
});
});

it('focuses the first interactive element', async () => {
const { getByRole } = render(
<FullScreen>
Expand All @@ -68,7 +127,7 @@ describe('document-capture/components/full-screen', () => {
});

it('focuses the close button as a fallback', async () => {
const { getByRole } = render(<FullScreen />);
const { getByRole } = render(<FullScreen>Content</FullScreen>);

await delay(); // focus-trap delays initial focus by default
expect(document.activeElement).to.equal(
Expand Down Expand Up @@ -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(
<>
<FullScreen>Please don’t</FullScreen>
<FullScreen>do this.</FullScreen>
Expand All @@ -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<FullScreenRefHandle>();
render(<FullScreen ref={ref}>Content</FullScreen>);

expect(ref.current.focusTrap.deactivate).to.be.a('function');
expect(ref.current!.focusTrap!.deactivate).to.be.a('function');
});
});
130 changes: 130 additions & 0 deletions app/javascript/packages/components/full-screen.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLElement | null>) {
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<FullScreenRefHandle>,
) {
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(
<div
ref={containerRef}
role="dialog"
aria-label={label}
aria-labelledby={labelledBy}
aria-describedby={describedBy}
className={`full-screen bg-${bgColor}`}
>
{children}
{!hideCloseButton && (
<button
type="button"
aria-label={t('users.personal_key.close')}
onClick={onRequestClose}
className="full-screen__close-button usa-button padding-2 margin-2"
>
<img
alt=""
src={getAssetPath('close-white-alt.svg')}
className="full-screen__close-icon"
/>
</button>
)}
</div>,
document.body,
);
}

export default forwardRef(FullScreen);
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand All @@ -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();
Expand All @@ -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.
Expand All @@ -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), {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<HTMLElement?>} containerRef
* @param {import('focus-trap').Options=} options
*
* @param {React.MutableRefObject<FocusTrap?>} containerRef
*/
function useFocusTrap(containerRef, options) {
const [trap, setTrap] = useState(/** @type {FocusTrap?} */ (null));
function useFocusTrap(containerRef: MutableRefObject<HTMLElement | null>, options?: Options) {
const [trap, setTrap] = useState(null as FocusTrap | null);

useEffect(() => {
let focusTrap;
Expand Down
Original file line number Diff line number Diff line change
@@ -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);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,25 +1,21 @@
import { useEffect } from 'react';
import type { ComponentType } from 'react';

/**
* @type {WeakMap<*, number>}
*/
const activeInstancesByType = new WeakMap();
const activeInstancesByType = new WeakMap<any, number>();

/**
* 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<any>} 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<any>) {
/**
* 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;

Expand Down
2 changes: 2 additions & 0 deletions app/javascript/packages/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Loading