Skip to content

Commit fb19d4f

Browse files
authored
fix: make the dev panel toggle keyboard operable (#235)
* fix: make the dev panel toggle keyboard operable * fix: move focus to the button that replaces the toggle
1 parent b47459b commit fb19d4f

5 files changed

Lines changed: 101 additions & 17 deletions

File tree

.changeset/shy-buttons-listen.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@hookform/devtools': patch
3+
---
4+
5+
- make the show/hide panel button operable with the keyboard, and move focus to
6+
the button that replaces it when toggling

src/__tests__/devToolUI.spec.tsx

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import * as React from 'react';
2+
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
3+
import { createStore, StateMachineProvider } from 'little-state-machine';
4+
import { useForm } from 'react-hook-form';
5+
6+
import { DevToolUI } from '../devToolUI';
7+
8+
createStore(
9+
{
10+
visible: false,
11+
isCollapse: false,
12+
filterName: '',
13+
},
14+
{
15+
name: '__REACT_HOOK_FORM_DEVTOOLS__',
16+
middleWares: [],
17+
},
18+
);
19+
20+
const App = () => {
21+
const { control } = useForm();
22+
23+
return (
24+
<StateMachineProvider>
25+
<DevToolUI control={control} />
26+
</StateMachineProvider>
27+
);
28+
};
29+
30+
describe('DevToolUI', () => {
31+
it('is operable with the keyboard and keeps focus on the toggle', async () => {
32+
render(<App />);
33+
34+
// Fails if the button has no accessible name.
35+
const showButton = await screen.findByRole('button', {
36+
name: 'Show dev panel',
37+
});
38+
39+
// Keyboard activation (Enter / Space) dispatches a click on the button,
40+
// so the handler has to live on the button and not on the svg inside it.
41+
fireEvent.click(showButton);
42+
43+
const closeButton = await screen.findByRole('button', {
44+
name: 'Close dev panel',
45+
});
46+
47+
// The button that was just used is unmounted, so focus has to move to the
48+
// one that replaced it instead of falling back to the body.
49+
await waitFor(() => expect(document.activeElement).toBe(closeButton));
50+
51+
fireEvent.click(closeButton);
52+
53+
await waitFor(() =>
54+
expect(document.activeElement).toBe(
55+
screen.getByRole('button', { name: 'Show dev panel' }),
56+
),
57+
);
58+
});
59+
});

src/devToolUI.tsx

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,22 @@ export const DevToolUI: React.FC<DevtoolUIProps> = ({
3434

3535
const position = getPositionByPlacement(placement, 0, 0);
3636

37+
const showButtonRef = React.useRef<HTMLButtonElement>(null);
38+
const closeButtonRef = React.useRef<HTMLButtonElement>(null);
39+
const previousVisible = React.useRef(state.visible);
40+
41+
// Toggling unmounts the button that was just used, which would drop keyboard
42+
// focus onto the body. Move it to the button that replaced it. Only on an
43+
// actual transition, so a panel that starts open never steals focus.
44+
React.useEffect(() => {
45+
if (previousVisible.current === state.visible) {
46+
return;
47+
}
48+
49+
previousVisible.current = state.visible;
50+
(state.visible ? closeButtonRef : showButtonRef).current?.focus();
51+
}, [state.visible]);
52+
3753
return (
3854
<>
3955
<Animate
@@ -72,13 +88,19 @@ export const DevToolUI: React.FC<DevtoolUIProps> = ({
7288
...styles?.panel,
7389
}}
7490
>
75-
<Header setVisible={actions.setVisible} control={control} />
91+
<Header
92+
setVisible={actions.setVisible}
93+
control={control}
94+
closeButtonRef={closeButtonRef}
95+
/>
7696
<Panel control={control} />
7797
</div>
7898
</Animate>
7999

80100
{!state.visible && (
81101
<Button
102+
ref={showButtonRef}
103+
aria-label="Show dev panel"
82104
title="Show dev panel"
83105
hideBackground
84106
style={{
@@ -91,8 +113,9 @@ export const DevToolUI: React.FC<DevtoolUIProps> = ({
91113
...styles?.button,
92114
}}
93115
type="button"
116+
onClick={() => actions.setVisible(true)}
94117
>
95-
<Logo actions={actions} />
118+
<Logo />
96119
</Button>
97120
)}
98121
</>

src/header.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@ import { CircleButton, paraGraphDefaultStyle } from './styled';
77
type Props = {
88
setVisible: any;
99
control: Control;
10+
closeButtonRef?: React.RefObject<HTMLButtonElement>;
1011
};
1112

12-
const Header = ({ setVisible, control }: Props) => {
13+
const Header = ({ setVisible, control, closeButtonRef }: Props) => {
1314
const { isValid } = useFormState({
1415
control,
1516
});
@@ -42,7 +43,12 @@ const Header = ({ setVisible, control }: Props) => {
4243
</span>{' '}
4344
React Hook Form
4445
</p>
45-
<CircleButton title="Close dev panel" onClick={() => setVisible(false)}>
46+
<CircleButton
47+
ref={closeButtonRef}
48+
aria-label="Close dev panel"
49+
title="Close dev panel"
50+
onClick={() => setVisible(false)}
51+
>
4652
4753
</CircleButton>
4854
</header>

src/logo.tsx

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,7 @@ import * as React from 'react';
22

33
import colors from './colors';
44

5-
const Logo = ({
6-
style,
7-
actions,
8-
}: {
9-
style?: Record<string, any>;
10-
actions: {
11-
setVisible: (arg: boolean) => void;
12-
};
13-
}) => {
5+
const Logo = ({ style }: { style?: Record<string, any> }) => {
146
return (
157
<svg
168
fill="white"
@@ -22,10 +14,8 @@ const Logo = ({
2214
background: colors.lightPink,
2315
...style,
2416
}}
25-
onClick={() => {
26-
actions.setVisible(true);
27-
}}
28-
aria-label="React Hook Form Logo"
17+
aria-hidden="true"
18+
focusable="false"
2919
>
3020
<path d="M73.56,13.32H58.14a8.54,8.54,0,0,0-16.27,0H26.44a11,11,0,0,0-11,11V81.63a11,11,0,0,0,11,11H73.56a11,11,0,0,0,11-11V24.32A11,11,0,0,0,73.56,13.32Zm-30.92,2a1,1,0,0,0,1-.79,6.54,6.54,0,0,1,12.78,0,1,1,0,0,0,1,.79h5.38v6.55a3,3,0,0,1-3,3H40.25a3,3,0,0,1-3-3V15.32ZM82.56,81.63a9,9,0,0,1-9,9H26.44a9,9,0,0,1-9-9V24.32a9,9,0,0,1,9-9h8.81v6.55a5,5,0,0,0,5,5h19.5a5,5,0,0,0,5-5V15.32h8.81a9,9,0,0,1,9,9Z" />
3121
<path

0 commit comments

Comments
 (0)