Skip to content
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
6 changes: 6 additions & 0 deletions .changeset/shy-buttons-listen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@hookform/devtools': patch
---

- make the show/hide panel button operable with the keyboard, and move focus to
the button that replaces it when toggling
59 changes: 59 additions & 0 deletions src/__tests__/devToolUI.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import * as React from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { createStore, StateMachineProvider } from 'little-state-machine';
import { useForm } from 'react-hook-form';

import { DevToolUI } from '../devToolUI';

createStore(
{
visible: false,
isCollapse: false,
filterName: '',
},
{
name: '__REACT_HOOK_FORM_DEVTOOLS__',
middleWares: [],
},
);

const App = () => {
const { control } = useForm();

return (
<StateMachineProvider>
<DevToolUI control={control} />
</StateMachineProvider>
);
};

describe('DevToolUI', () => {
it('is operable with the keyboard and keeps focus on the toggle', async () => {
render(<App />);

// Fails if the button has no accessible name.
const showButton = await screen.findByRole('button', {
name: 'Show dev panel',
});

// Keyboard activation (Enter / Space) dispatches a click on the button,
// so the handler has to live on the button and not on the svg inside it.
fireEvent.click(showButton);

const closeButton = await screen.findByRole('button', {
name: 'Close dev panel',
});

// The button that was just used is unmounted, so focus has to move to the
// one that replaced it instead of falling back to the body.
await waitFor(() => expect(document.activeElement).toBe(closeButton));

fireEvent.click(closeButton);

await waitFor(() =>
expect(document.activeElement).toBe(
screen.getByRole('button', { name: 'Show dev panel' }),
),
);
});
});
27 changes: 25 additions & 2 deletions src/devToolUI.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,22 @@ export const DevToolUI: React.FC<DevtoolUIProps> = ({

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

const showButtonRef = React.useRef<HTMLButtonElement>(null);
const closeButtonRef = React.useRef<HTMLButtonElement>(null);
const previousVisible = React.useRef(state.visible);

// Toggling unmounts the button that was just used, which would drop keyboard
// focus onto the body. Move it to the button that replaced it. Only on an
// actual transition, so a panel that starts open never steals focus.
React.useEffect(() => {
if (previousVisible.current === state.visible) {
return;
}

previousVisible.current = state.visible;
(state.visible ? closeButtonRef : showButtonRef).current?.focus();
}, [state.visible]);

return (
<>
<Animate
Expand Down Expand Up @@ -72,13 +88,19 @@ export const DevToolUI: React.FC<DevtoolUIProps> = ({
...styles?.panel,
}}
>
<Header setVisible={actions.setVisible} control={control} />
<Header
setVisible={actions.setVisible}
control={control}
closeButtonRef={closeButtonRef}
/>
<Panel control={control} />
</div>
</Animate>

{!state.visible && (
<Button
ref={showButtonRef}
aria-label="Show dev panel"
title="Show dev panel"
hideBackground
style={{
Expand All @@ -91,8 +113,9 @@ export const DevToolUI: React.FC<DevtoolUIProps> = ({
...styles?.button,
}}
type="button"
onClick={() => actions.setVisible(true)}
>
<Logo actions={actions} />
<Logo />
</Button>
)}
</>
Expand Down
10 changes: 8 additions & 2 deletions src/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@ import { CircleButton, paraGraphDefaultStyle } from './styled';
type Props = {
setVisible: any;
control: Control;
closeButtonRef?: React.RefObject<HTMLButtonElement>;
};

const Header = ({ setVisible, control }: Props) => {
const Header = ({ setVisible, control, closeButtonRef }: Props) => {
const { isValid } = useFormState({
control,
});
Expand Down Expand Up @@ -42,7 +43,12 @@ const Header = ({ setVisible, control }: Props) => {
</span>{' '}
React Hook Form
</p>
<CircleButton title="Close dev panel" onClick={() => setVisible(false)}>
<CircleButton
ref={closeButtonRef}
aria-label="Close dev panel"
title="Close dev panel"
onClick={() => setVisible(false)}
>
</CircleButton>
</header>
Expand Down
16 changes: 3 additions & 13 deletions src/logo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,7 @@ import * as React from 'react';

import colors from './colors';

const Logo = ({
style,
actions,
}: {
style?: Record<string, any>;
actions: {
setVisible: (arg: boolean) => void;
};
}) => {
const Logo = ({ style }: { style?: Record<string, any> }) => {
return (
<svg
fill="white"
Expand All @@ -22,10 +14,8 @@ const Logo = ({
background: colors.lightPink,
...style,
}}
onClick={() => {
actions.setVisible(true);
}}
aria-label="React Hook Form Logo"
aria-hidden="true"
focusable="false"
>
<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" />
<path
Expand Down