Skip to content

Commit

Permalink
feat: focus utils
Browse files Browse the repository at this point in the history
  • Loading branch information
renrizzolo committed Mar 23, 2023
1 parent 8078b67 commit ee1064a
Show file tree
Hide file tree
Showing 17 changed files with 1,084 additions and 136 deletions.
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;
cancelButton?: React.ReactNode;
isModal?: boolean;
disableFocusTrap?: boolean;
/**
* Hides the modal with css, but keeps it mounted.
* This should only be used if you need to launch an ActionPanel
Expand Down
65 changes: 51 additions & 14 deletions src/components/ActionPanel/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,26 @@ import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import ReactDOM from 'react-dom';
import DismissibleFocusTrap from '../DismissibleFocusTrap';
import { expandDts } from '../../utils';
import Button from '../Button';
import './styles.css';

const ActionPanel = React.forwardRef((props, ref) => {
const { title, className, size, onClose, children, visuallyHidden, actionButton, isModal, cancelButton, dts } = props;
const {
title,
className,
size,
onClose,
onEscapeClose,
children,
visuallyHidden,
actionButton,
isModal,
cancelButton,
disableFocusTrap,
dts,
} = props;

const addBodyClass = (classname) => document.body.classList.add(classname);
const removeBodyClass = (classname) => document.body.classList.remove(classname);
Expand All @@ -21,6 +35,12 @@ const ActionPanel = React.forwardRef((props, ref) => {
};
}, [isModal, visuallyHidden]);

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

const defaultCancelButton = (
<>
{actionButton ? (
Expand Down Expand Up @@ -52,25 +72,34 @@ 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 })}
<DismissibleFocusTrap
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">
{cancelButton ? cancelButton : defaultCancelButton}
{actionButton}
</span>
</div>
<div data-testid="action-panel-body" className="aui--action-panel-body">
{children}
</div>
<span className="actions">
{cancelButton ? cancelButton : defaultCancelButton}
{actionButton}
</span>
</div>
<div data-testid="action-panel-body" className="aui--action-panel-body">
{children}
</div>
</DismissibleFocusTrap>
</div>
</div>
</div>
Expand All @@ -85,10 +114,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,
cancelButton: PropTypes.node,
isModal: PropTypes.bool,
disableFocusTrap: PropTypes.bool,
/**
* Hides the modal with css, but keeps it mounted.
* This should only be used if you need to launch an ActionPanel
Expand Down
135 changes: 135 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,12 +69,138 @@ 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.tab();
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.tab();
userEvent.keyboard('[Escape]');
});
expect(onClose).not.toBeCalled();
});

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

expect(getByTestId('action-panel-modal-wrapper')).toHaveClass('visually-hidden');
});

it('should focus the originally focussed element when closing a nested action panel', () => {
const TestComponent = () => {
const [showNestedActionPanel, setShowNestedActionPanel] = React.useState();
return (
<ActionPanel {...makeProps({ isModal: true, visuallyHidden: showNestedActionPanel })}>
<button
data-testid="show-nested"
onClick={() => {
setShowNestedActionPanel(true);
}}
/>
{showNestedActionPanel && (
<ActionPanel
{...makeProps({ isModal: true })}
cancelButton={<button data-testid="nested-cancel" onClick={() => setShowNestedActionPanel(false)} />}
>
...
</ActionPanel>
)}
</ActionPanel>
);
};
const { getByTestId, getAllByTestId } = render(<TestComponent />);

act(() => {
userEvent.tab();
expect(getByTestId('show-nested')).toHaveFocus();
userEvent.keyboard('[Enter]');
});

expect(getAllByTestId('action-panel-modal-wrapper')[0]).toHaveClass('visually-hidden');
expect(getAllByTestId('action-panel-wrapper')).toHaveLength(2);
expect(getByTestId('nested-cancel')).toHaveFocus();

act(() => {
userEvent.keyboard('[Enter]');
});

act(() => jest.runAllTimers());

expect(getByTestId('show-nested')).toHaveFocus();
});

it('should render a user specified text on the cancel button', () => {
let wrapper;
act(() => {
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 ee1064a

Please sign in to comment.