Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: focus utils #1556

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
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
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