Skip to content

Commit

Permalink
Merge pull request klembot#1074 from klembot/focus-prompts
Browse files Browse the repository at this point in the history
Improve focus handling in <CardButton>
  • Loading branch information
klembot authored Feb 27, 2022
2 parents 79de743 + 165cc08 commit 2117d58
Show file tree
Hide file tree
Showing 8 changed files with 157 additions and 36 deletions.
20 changes: 18 additions & 2 deletions src/components/control/__tests__/card-button.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,19 @@ import * as React from 'react';
import {CardButton, CardButtonProps} from '../card-button';

describe('<CardButton>', () => {
async function renderComponent(props?: Partial<CardButtonProps>) {
async function renderComponent(
props?: Partial<CardButtonProps>,
children?: React.ReactNode
) {
const result = render(
<CardButton
ariaLabel="mock-aria-label"
icon="mock-icon"
label="mock-label"
onChangeOpen={jest.fn()}
{...props}
>
mock-card-button-child
{children ?? <button>mock-card-button-child</button>}
</CardButton>
);

Expand Down Expand Up @@ -61,6 +65,18 @@ describe('<CardButton>', () => {
expect(onChangeOpen.mock.calls).toEqual([[false]]);
});

// This also works in isolation but not with other tests--unsure why.

it.skip('focuses the first text input in the card contents when open', async () => {
renderComponent(
{
open: true
},
<input data-testid="text-field" type="text" />
);
expect(screen.getByTestId('text-field')).toHaveFocus();
});

it('is accessible', async () => {
const {container} = await renderComponent();

Expand Down
81 changes: 80 additions & 1 deletion src/components/control/card-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,86 @@ import {IconButton, IconButtonProps} from './icon-button';
import './card-button.css';

export interface CardButtonProps extends Omit<IconButtonProps, 'onClick'> {
/**
* ARIA label for the card that opens.
*/
ariaLabel: string;
/**
* CSS selector for the element inside the card that should be focused when it
* is opened. Otherwise, the first text input or button in source order will
* be focused.
*/
focusSelector?: string;
/**
* Callback for when the open status of the card should change.
*/
onChangeOpen: (value: boolean) => void;
/**
* Is the card currently open?
*/
open?: boolean;
}

export const CardButton: React.FC<CardButtonProps> = props => {
const {children, onChangeOpen, open, ...other} = props;
const {ariaLabel, children, focusSelector, onChangeOpen, open, ...other} =
props;
const [buttonEl, setButtonEl] = React.useState<HTMLButtonElement | null>(
null
);
const [cardEl, setCardEl] = React.useState<HTMLDivElement | null>(null);
const [previousFocusEl, setPreviousFocusEl] =
React.useState<HTMLElement | null>(null);
const {styles, attributes} = usePopper(buttonEl, cardEl, {strategy: 'fixed'});

// Move focus inside the card when it's opened.

React.useEffect(() => {
if (open && cardEl) {
setPreviousFocusEl(document.activeElement as HTMLElement | null);

// Use the focusSelector prop if that was specified;

if (focusSelector) {
const focusEl = cardEl.querySelector<HTMLElement>(focusSelector);

if (focusEl) {
// Select it if possible--e.g it's a text field.

if (typeof (focusEl as any).select === 'function') {
(focusEl as any).select();
}

focusEl.focus();
return;
}
}

// Prefer a text input if one's available.

const input =
cardEl.querySelector<HTMLInputElement>('input[type="text"]');

if (input) {
input.select();
input.focus();
return;
}

// Otherwise, choose the first button.

const button = cardEl.querySelector<HTMLButtonElement>('button');

if (button) {
button.focus();
}

// If neither is available, we've messed up--there needs to be at least
// one focusable element.
}
}, [cardEl, focusSelector, open]);

// Close an opened card if a click event occurs outside it.

React.useEffect(() => {
const closer = (event: MouseEvent) => {
let target: Node | null = event.target as HTMLElement;
Expand All @@ -37,6 +105,15 @@ export const CardButton: React.FC<CardButtonProps> = props => {
}
}, [cardEl, onChangeOpen, open, props]);

// Restore focus when the card is closed.

React.useEffect(() => {
if (!open && previousFocusEl) {
previousFocusEl.focus();
setPreviousFocusEl(null);
}
}, [open, previousFocusEl]);

return (
<span className="card-button">
<IconButton
Expand All @@ -52,8 +129,10 @@ export const CardButton: React.FC<CardButtonProps> = props => {
unmountOnExit
>
<div
aria-label={ariaLabel}
className="card-button-card"
ref={setCardEl}
role="dialog"
style={styles.popper}
{...attributes.popper}
>
Expand Down
10 changes: 8 additions & 2 deletions src/components/control/confirm-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {IconButton, IconButtonProps} from './icon-button';
import './confirm-button.css';

export interface ConfirmButtonProps
extends Omit<CardButtonProps, 'open' | 'onChangeOpen'> {
extends Omit<CardButtonProps, 'ariaLabel' | 'open' | 'onChangeOpen'> {
cancelIcon?: React.ReactNode;
cancelLabel?: string;
confirmIcon?: React.ReactNode;
Expand Down Expand Up @@ -39,7 +39,13 @@ export const ConfirmButton: React.FC<ConfirmButtonProps> = props => {

return (
<span className="confirm-button">
<CardButton onChangeOpen={setOpen} open={open} {...other}>
<CardButton
ariaLabel={prompt}
focusSelector="button:nth-child(2)"
onChangeOpen={setOpen}
open={open}
{...other}
>
<CardContent>{prompt}</CardContent>
<ButtonBar>
<IconButton
Expand Down
15 changes: 9 additions & 6 deletions src/components/control/prompt-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export type PromptButtonValidator = (
) => PromptValidationResponse | Promise<PromptValidationResponse>;

export interface PromptButtonProps
extends Omit<CardButtonProps, 'onChangeOpen' | 'open'> {
extends Omit<CardButtonProps, 'ariaLabel' | 'onChangeOpen' | 'open'> {
cancelIcon?: React.ReactNode;
cancelLabel?: string;
onChange: React.ChangeEventHandler<HTMLInputElement>;
Expand Down Expand Up @@ -45,10 +45,8 @@ export const PromptButton: React.FC<PromptButtonProps> = props => {
...other
} = props;
const [open, setOpen] = React.useState(false);
const [
validation,
setValidation
] = React.useState<PromptValidationResponse>();
const [validation, setValidation] =
React.useState<PromptValidationResponse>();
const {t} = useTranslation();

React.useEffect(() => {
Expand All @@ -70,7 +68,12 @@ export const PromptButton: React.FC<PromptButtonProps> = props => {

return (
<span className="prompt-button">
<CardButton onChangeOpen={setOpen} open={open} {...other}>
<CardButton
ariaLabel={prompt}
onChangeOpen={setOpen}
open={open}
{...other}
>
<CardContent>
<TextInput onChange={onChange} orientation="vertical" value={value}>
{prompt}
Expand Down
8 changes: 4 additions & 4 deletions src/components/tag/__tests__/add-tag-button.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ describe('<AddTagButton>', () => {
fireEvent.click(screen.getByText('common.tag'));
await act(() => Promise.resolve()); // Wait for <CardButton> to open
fireEvent.change(
screen.getByLabelText('components.addTagButton.addLabel'),
screen.getByRole('combobox', {name: 'components.addTagButton.addLabel'}),
{target: {value: 'existing-tag'}}
);
fireEvent.click(screen.getByText('common.add'));
Expand All @@ -54,7 +54,7 @@ describe('<AddTagButton>', () => {
expect(onAdd).not.toHaveBeenCalled();
fireEvent.click(screen.getByText('common.tag'));
fireEvent.change(
screen.getByLabelText('components.addTagButton.addLabel'),
screen.getByRole('combobox', {name: 'components.addTagButton.addLabel'}),
{target: {value: ''}}
);
fireEvent.change(
Expand All @@ -76,7 +76,7 @@ describe('<AddTagButton>', () => {
expect(onAdd).not.toHaveBeenCalled();
fireEvent.click(screen.getByText('common.tag'));
fireEvent.change(
screen.getByLabelText('components.addTagButton.addLabel'),
screen.getByRole('combobox', {name: 'components.addTagButton.addLabel'}),
{target: {value: ''}}
);
fireEvent.change(
Expand All @@ -98,7 +98,7 @@ describe('<AddTagButton>', () => {
expect(onAdd).not.toHaveBeenCalled();
fireEvent.click(screen.getByText('common.tag'));
fireEvent.change(
screen.getByLabelText('components.addTagButton.addLabel'),
screen.getByRole('combobox', {name: 'components.addTagButton.addLabel'}),
{target: {value: ''}}
);
fireEvent.change(
Expand Down
18 changes: 12 additions & 6 deletions src/components/tag/__tests__/tag-editor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,12 @@ describe('<TagEditor>', () => {
await renderComponent({onChangeName});
expect(onChangeName).not.toHaveBeenCalled();
fireEvent.click(screen.getByText('common.rename'));
fireEvent.change(screen.getByLabelText('common.renamePrompt'), {
target: {value: 'new-name'}
});
fireEvent.change(
screen.getByRole('textbox', {name: 'common.renamePrompt'}),
{
target: {value: 'new-name'}
}
);
await act(async () => Promise.resolve()); // Wait for <PromptButton> to update
fireEvent.click(screen.getByText('common.ok'));
expect(onChangeName.mock.calls).toEqual([['new-name']]);
Expand All @@ -54,9 +57,12 @@ describe('<TagEditor>', () => {
it('does not allow renaming the tag to a pre-existing name', async () => {
await renderComponent({allTags: ['already-exists']});
fireEvent.click(screen.getByText('common.rename'));
fireEvent.change(screen.getByLabelText('common.renamePrompt'), {
target: {value: 'already-exists'}
});
fireEvent.change(
screen.getByRole('textbox', {name: 'common.renamePrompt'}),
{
target: {value: 'already-exists'}
}
);
await act(async () => Promise.resolve()); // Wait for <PromptButton> to update
expect(screen.getByText('common.ok')).toBeDisabled();
});
Expand Down
1 change: 1 addition & 0 deletions src/components/tag/add-tag-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ export const AddTagButton: React.FC<AddTagButtonProps> = props => {
return (
<span className="add-tag-button">
<CardButton
ariaLabel={t('components.addTagButton.addLabel')}
disabled={disabled}
icon={icon ?? <IconPlus />}
label={label ?? t('common.tag')}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import {act, fireEvent, render, screen, waitFor} from '@testing-library/react';
import {
act,
fireEvent,
render,
screen,
waitFor,
within
} from '@testing-library/react';
import {axe} from 'jest-axe';
import * as React from 'react';
import {
Expand All @@ -14,10 +21,13 @@ import {AddStoryFormatButton} from '../add-story-format-button';
jest.mock('../../../../../util/story-format');

const getAddButton = () =>
screen.getByText('common.add', {selector: '.card-button-card button'});
within(document.querySelector('.card-button-card')!).getByRole('button', {
name: 'common.add'
});

describe('<AddStoryFormatButton>', () => {
const fetchStoryFormatPropertiesMock = fetchStoryFormatProperties as jest.Mock;
const fetchStoryFormatPropertiesMock =
fetchStoryFormatProperties as jest.Mock;

beforeEach(() => {
fetchStoryFormatPropertiesMock.mockReturnValue(fakeStoryFormatProperties());
Expand Down Expand Up @@ -49,9 +59,9 @@ describe('<AddStoryFormatButton>', () => {
screen.queryByTestId('story-format-inspector-default')
).not.toBeInTheDocument();
fireEvent.change(
screen.getByLabelText(
'routes.storyFormatList.toolbar.addStoryFormatButton.prompt'
),
screen.getByRole('textbox', {
name: 'routes.storyFormatList.toolbar.addStoryFormatButton.prompt'
}),
{target: {value: 'http://mock-format-url'}}
);
await waitFor(() => Promise.resolve());
Expand All @@ -68,9 +78,9 @@ describe('<AddStoryFormatButton>', () => {
it('shows an error if an invalid URL is entered', async () => {
await renderComponent();
fireEvent.change(
screen.getByLabelText(
'routes.storyFormatList.toolbar.addStoryFormatButton.prompt'
),
screen.getByRole('textbox', {
name: 'routes.storyFormatList.toolbar.addStoryFormatButton.prompt'
}),
{target: {value: 'not a url'}}
);
await act(async () => Promise.resolve());
Expand All @@ -86,9 +96,9 @@ describe('<AddStoryFormatButton>', () => {
fetchStoryFormatPropertiesMock.mockRejectedValue(new Error());
await renderComponent();
fireEvent.change(
screen.getByLabelText(
'routes.storyFormatList.toolbar.addStoryFormatButton.prompt'
),
screen.getByRole('textbox', {
name: 'routes.storyFormatList.toolbar.addStoryFormatButton.prompt'
}),
{target: {value: 'http://mock-format-url'}}
);
await act(async () => Promise.resolve());
Expand All @@ -108,9 +118,9 @@ describe('<AddStoryFormatButton>', () => {
);
await renderComponent({storyFormats: [format]});
fireEvent.change(
screen.getByLabelText(
'routes.storyFormatList.toolbar.addStoryFormatButton.prompt'
),
screen.getByRole('textbox', {
name: 'routes.storyFormatList.toolbar.addStoryFormatButton.prompt'
}),
{target: {value: 'http://mock-format-url'}}
);
await act(async () => Promise.resolve());
Expand Down

0 comments on commit 2117d58

Please sign in to comment.