Skip to content

Commit

Permalink
feat: focus utils
Browse files Browse the repository at this point in the history
  • Loading branch information
renrizzolo committed Dec 21, 2022
1 parent 21057bc commit 986c253
Show file tree
Hide file tree
Showing 17 changed files with 922 additions and 74 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,6 @@ dist

lib

!src/lib

es
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ package-lock.json
dist
es
lib
!src/lib
2 changes: 1 addition & 1 deletion scripts/generate-types/generateTypes.js
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ async function generateTypeDefs() {
await Promise.all(
parsed.map(async (code, i) => {
const result = await generateFromSource(null, code, {
babylonPlugins: ['exportDefaultFrom', 'transformImports'],
babylonPlugins: ['exportDefaultFrom', 'transformImports', 'nullishCoalescingOperator'],
});

const component = allComponents[i];
Expand Down
7 changes: 7 additions & 0 deletions src/components/ActionPanel/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,17 @@ export interface ActionPanelProps {
className?: string;
size?: ActionPanelSize;
onClose: (...args: any[]) => any;
/**
* @param event
* called before `onClose` is called, when pressing escape.
* can be prevented with `event.preventDefault()`
*/
onEscapeClose?: (...args: any[]) => any;
children: React.ReactNode;
actionButton?: React.ReactNode;
closeIcon?: React.ReactNode;
isModal?: boolean;
disableFocusTrap?: boolean;
cancelText?: string;
/**
* Hides the modal with css, but keeps it mounted.
Expand Down
75 changes: 50 additions & 25 deletions src/components/ActionPanel/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import ReactDOM from 'react-dom';
import DismissableFocusTrap from '../DismissableFocusTrap';
import { expandDts } from '../../lib/utils';
import Button from '../Button';
import './styles.css';
Expand All @@ -12,12 +13,14 @@ const ActionPanel = React.forwardRef((props, ref) => {
className,
size,
onClose,
onEscapeClose,
children,
visuallyHidden,
actionButton,
isModal,
closeIcon,
cancelText,
disableFocusTrap,
dts,
} = props;

Expand All @@ -33,6 +36,12 @@ const ActionPanel = React.forwardRef((props, ref) => {
};
}, [isModal, visuallyHidden]);

const onEscapeHandler = (event) => {
onEscapeClose?.(event);
if (event.defaultPrevented) return;
onClose();
};

const actionPanel = (
<div ref={ref}>
<div
Expand All @@ -46,37 +55,45 @@ const ActionPanel = React.forwardRef((props, ref) => {
})}
>
<div
aria-modal={isModal ? 'true' : undefined}
aria-label={title}
role={isModal ? 'dialog' : undefined}
data-testid="action-panel-wrapper"
className={classNames('aui--action-panel', `is-${size}`, { 'action-modal': isModal }, className)}
{...expandDts(dts)}
>
<div
data-testid="action-panel-header"
className={classNames('aui--action-panel-header', { 'has-actions': actionButton })}
<DismissableFocusTrap
disabled={disableFocusTrap ?? !isModal}
onEscape={isModal && !visuallyHidden ? onEscapeHandler : undefined}
>
<div data-testid="action-panel-title" className="title">
{title}
<div
data-testid="action-panel-header"
className={classNames('aui--action-panel-header', { 'has-actions': actionButton })}
>
<div data-testid="action-panel-title" className="title">
{title}
</div>
<span className="actions">
{actionButton ? (
<Button onClick={onClose} className="close-button" dts="header-close-button">
{cancelText}
</Button>
) : (
<Button
onClick={onClose}
className={classNames('close-button', 'close-svg-icon')}
dts="header-close-button"
icon={closeIcon}
aria-label={'Close'}
/>
)}
{actionButton}
</span>
</div>
<div data-testid="action-panel-body" className="aui--action-panel-body">
{children}
</div>
<span className="actions">
{actionButton ? (
<Button onClick={onClose} className="close-button" dts="header-close-button">
{cancelText}
</Button>
) : (
<Button
onClick={onClose}
className={classNames('close-button', 'close-svg-icon')}
dts="header-close-button"
icon={closeIcon}
aria-label={'Close'}
/>
)}
{actionButton}
</span>
</div>
<div data-testid="action-panel-body" className="aui--action-panel-body">
{children}
</div>
</DismissableFocusTrap>
</div>
</div>
</div>
Expand All @@ -91,10 +108,18 @@ ActionPanel.propTypes = {
// large is intended to be used in a modal
size: PropTypes.oneOf(['small', 'medium', 'large']),
onClose: PropTypes.func.isRequired,
/**
* @param event
* called before `onClose` is called, when pressing escape.
*
* can be prevented with `event.preventDefault()`
*/
onEscapeClose: PropTypes.func,
children: PropTypes.node.isRequired,
actionButton: PropTypes.node,
closeIcon: PropTypes.node,
isModal: PropTypes.bool,
disableFocusTrap: PropTypes.bool,
cancelText: PropTypes.string,
/**
* Hides the modal with css, but keeps it mounted.
Expand Down
108 changes: 108 additions & 0 deletions src/components/ActionPanel/index.spec.jsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
import _ from 'lodash';
import React from 'react';
import { act, render, cleanup } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Button from '../Button';
import ActionPanel from '.';

beforeEach(() => {
jest.useFakeTimers();
});

afterEach(() => {
jest.useRealTimers();
});

afterEach(cleanup);

describe('<ActionPanel />', () => {
Expand Down Expand Up @@ -60,6 +69,105 @@ describe('<ActionPanel />', () => {
expect(document.body).not.toHaveClass('modal-open');
});

it('should trap focus inside the modal', () => {
const { getAllByRole } = render(
<ActionPanel {...makeProps({ isModal: true })}>
<button>Button</button>
<input type={'search'} />
</ActionPanel>
);
act(() => {
jest.runAllTimers();
});

expect(getAllByRole('button').at(0)).toHaveFocus();

act(() => {
userEvent.tab();
jest.runAllTimers();
});
expect(getAllByRole('button').at(1)).toHaveFocus();
act(() => {
userEvent.tab();
jest.runAllTimers();
});
expect(getAllByRole('searchbox').at(0)).toHaveFocus();

act(() => {
userEvent.tab();
jest.runAllTimers();
});
expect(getAllByRole('button').at(0)).toHaveFocus();

act(() => {
userEvent.tab({ shift: true });
jest.runAllTimers();
});

expect(getAllByRole('searchbox').at(0)).toHaveFocus();
act(() => {
userEvent.tab({ shift: true });
jest.runAllTimers();
});

expect(getAllByRole('button').at(1)).toHaveFocus();

act(() => {
userEvent.tab({ shift: true });
jest.runAllTimers();
});
expect(getAllByRole('button').at(0)).toHaveFocus();
});

it('should call onEscapeClose', () => {
const onEscapeClose = jest.fn();
render(
<ActionPanel {...makeProps({ isModal: true, onEscapeClose })}>
<button>Button</button>
<input type={'search'} />
</ActionPanel>
);

act(() => {
userEvent.keyboard('[Escape]');
});
expect(onEscapeClose).toBeCalledTimes(1);
});

it('should not close when call onEscapeClose prevents default', () => {
const onEscapeClose = (e) => e.preventDefault();
const onClose = jest.fn();
render(
<ActionPanel {...makeProps({ isModal: true, onClose, onEscapeClose })}>
<button>Button</button>
<input type={'search'} />
</ActionPanel>
);

act(() => {
userEvent.keyboard('[Escape]');
});
expect(onClose).not.toBeCalled();
});

it('should not close when onClickOutsideClose prevents default', () => {
const onClickOutsideClose = (e) => e.preventDefault();
const onClose = jest.fn();
const { getByTestId } = render(
<div data-testid="outer">
<ActionPanel {...makeProps({ isModal: true, onClose, onClickOutsideClose })}>
<button>Button</button>
<input type={'search'} />
</ActionPanel>
</div>
);

act(() => {
userEvent.click(getByTestId('outer'));
});
expect(onClose).not.toBeCalled();
});

it('should hide the modal with the visuallyHidden prop', () => {
const { getByTestId } = render(<ActionPanel {...makeProps({ isModal: true, visuallyHidden: true })} />);

Expand Down
81 changes: 44 additions & 37 deletions src/components/ConfirmModal/__snapshots__/index.spec.jsx.snap
Original file line number Diff line number Diff line change
Expand Up @@ -2,53 +2,60 @@

exports[`<ConfirmModal /> should show modal when \`show\` is true 1`] = `
<div
aria-label=""
aria-modal="true"
class="aui--action-panel is-small action-modal confirm-modal-component"
data-testid="action-panel-wrapper"
role="dialog"
>
<div
class="aui--action-panel-header has-actions"
data-testid="action-panel-header"
data-testid="focus-trap"
>
<div
class="title"
data-testid="action-panel-title"
/>
<span
class="actions"
class="aui--action-panel-header has-actions"
data-testid="action-panel-header"
>
<button
class="aui--button close-button aui-default aui-inverse"
data-test-selector="header-close-button"
data-testid="button-wrapper"
type="button"
<div
class="title"
data-testid="action-panel-title"
/>
<span
class="actions"
>
<span
class="aui-children-container"
<button
class="aui--button close-button aui-default aui-inverse"
data-test-selector="header-close-button"
data-testid="button-wrapper"
type="button"
>
Cancel
</span>
</button>
<button
class="aui--button aui-primary"
data-test-selector="confirm-modal-confirm"
data-testid="confirm-modal-confirm"
type="button"
>
<span
class="aui-children-container"
<span
class="aui-children-container"
>
Cancel
</span>
</button>
<button
class="aui--button aui-primary"
data-test-selector="confirm-modal-confirm"
data-testid="confirm-modal-confirm"
type="button"
>
Confirm
</span>
</button>
</span>
</div>
<div
class="aui--action-panel-body"
data-testid="action-panel-body"
>
<p>
Are you sure?
</p>
<span
class="aui-children-container"
>
Confirm
</span>
</button>
</span>
</div>
<div
class="aui--action-panel-body"
data-testid="action-panel-body"
>
<p>
Are you sure?
</p>
</div>
</div>
</div>
`;
Loading

0 comments on commit 986c253

Please sign in to comment.