Skip to content

Commit ecdb769

Browse files
committed
feat: add useA11yButton hook
This provides a hook for people who don't want to use the component wrapper pattern and removes the default export BREAKING CHANGE: The Button component is no longer a default export. It's a named export e.g. `import {Button} from '@accessible/button'`
1 parent 4b214dd commit ecdb769

File tree

4 files changed

+137
-100
lines changed

4 files changed

+137
-100
lines changed

README.md

+45-18
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,9 @@ provides interoperability between `<button>` elements and those faux button elem
4545
[Check out the example on CodeSandbox](https://codesandbox.io/s/accessiblebutton-example-spjh2)
4646

4747
```jsx harmony
48-
import Button from '@accessible/button'
48+
import {Button, useA11yButton} from '@accessible/button'
4949

50-
const ComponentA = () => (
50+
const Component = () => (
5151
// Adds `space` and `enter` keydown handlers to the div,
5252
// also adds role='button' and tabIndex='0', both
5353
// of which can be overridden by providing those
@@ -58,31 +58,58 @@ const ComponentA = () => (
5858
// <div role='button' tabindex='0'/>
5959
)
6060

61-
const ComponentB = () => (
62-
// Won't break real buttons
63-
<Button>
64-
<button onClick={console.log} />
65-
</Button>
66-
)
67-
68-
const MyButton = styled.button``
69-
70-
const ComponentC = () => (
71-
// Won't break real buttons, period.
72-
<Button>
73-
<MyButton onClick={console.log} />
74-
</Button>
75-
)
61+
const WithHook = () => {
62+
const ref = React.useRef(null)
63+
const a11yProps = useA11yButton(ref, (event) => {
64+
// This is your `onClick` handler
65+
console.log('Clicked', event)
66+
})
67+
return <button {...a11yProps} ref={ref} />
68+
}
7669
```
7770

7871
## API
7972

80-
### Props
73+
### &lt;Button&gt;
74+
75+
#### Props
8176

8277
| Prop | Type | Default | Required? | Description |
8378
| -------- | -------------------- | ----------- | --------- | ---------------------------------------------------------------------------------------------------- |
8479
| children | `React.ReactElement` | `undefined` | Yes | The component you want to turn into a button that handles focus and `space`, `enter` keydown events. |
8580

81+
### useA11yButton(target, onClick)
82+
83+
A React hook for adding a11y properties and button/role=button interop to elements.
84+
85+
```jsx harmony
86+
const Button = () => {
87+
const ref = React.useRef(null)
88+
const a11yProps = useA11yButton(ref, (event) => {
89+
// This is your `onClick` handler
90+
console.log('Clicked', event)
91+
})
92+
return <div {...a11yProps} ref={ref} />
93+
}
94+
```
95+
96+
#### Arguments
97+
98+
| Argument | Type | Required? | Description |
99+
| -------- | ---------------------------------------------------------- | --------- | ---------------------------------------------------------------------------------------------------- |
100+
| target | <code>React.RefObject&lt;T&gt; &#124; T &#124; null</code> | Yes | A React ref or HTML element | |
101+
| children | `React.ReactElement` | Yes | The component you want to turn into a button that handles focus and `space`, `enter` keydown events. |
102+
103+
#### Returns
104+
105+
```ts
106+
{
107+
readonly onClick: (event: React.MouseEvent<T, MouseEvent>) => void;
108+
readonly role: "button";
109+
readonly tabIndex: 0;
110+
}
111+
```
112+
86113
## LICENSE
87114

88115
MIT

src/index.test.tsx

100644100755
+30-41
Original file line numberDiff line numberDiff line change
@@ -1,102 +1,91 @@
1+
/* eslint-disable jsx-a11y/click-events-have-key-events */
12
/* jest */
2-
import React from 'react'
3-
import {render, fireEvent} from '@testing-library/react'
4-
import Button from './index'
3+
import * as React from 'react'
4+
import {render, fireEvent, screen} from '@testing-library/react'
5+
import userEvent from '@testing-library/user-event'
6+
import {Button} from './index'
57

68
describe('<Button>', () => {
79
it(`should fire click event once for buttons on click`, () => {
810
const cb = jest.fn()
9-
const {getByTestId} = render(
11+
render(
1012
<Button>
11-
<button onClick={cb} data-testid="btn" />
13+
<button onClick={cb} />
1214
</Button>
1315
)
1416
// mousedown
15-
fireEvent.click(getByTestId('btn'))
17+
fireEvent.click(screen.getByRole('button'))
1618
expect(cb).toBeCalledTimes(0)
17-
fireEvent.mouseDown(getByTestId('btn'))
18-
fireEvent.click(getByTestId('btn'))
19+
fireEvent.mouseDown(screen.getByRole('button'))
20+
fireEvent.click(screen.getByRole('button'))
1921
expect(cb).toBeCalledTimes(1)
2022
// touchstart
21-
fireEvent.click(getByTestId('btn'))
23+
fireEvent.click(screen.getByRole('button'))
2224
expect(cb).toBeCalledTimes(1) // should reset between clicks
23-
fireEvent.touchStart(getByTestId('btn'))
24-
fireEvent.click(getByTestId('btn'))
25+
fireEvent.touchStart(screen.getByRole('button'))
26+
fireEvent.click(screen.getByRole('button'))
2527
expect(cb).toBeCalledTimes(2)
2628
})
2729

2830
it(`should fire click event once for buttons on space`, () => {
2931
const cb = jest.fn()
30-
const {getByTestId} = render(
32+
render(
3133
<Button>
32-
<button onClick={cb} data-testid="btn" />
34+
<button onClick={cb} />
3335
</Button>
3436
)
3537

36-
fireEvent.keyDown(getByTestId('btn'), {which: 32})
38+
fireEvent.keyDown(screen.getByRole('button'), {key: ' '})
3739
expect(cb).toBeCalledTimes(1)
3840
})
3941

4042
it(`should fire click event once for buttons on enter`, () => {
4143
const cb = jest.fn()
42-
const {getByTestId} = render(
44+
render(
4345
<Button>
44-
<button onClick={cb} data-testid="btn" />
46+
<button onClick={cb} />
4547
</Button>
4648
)
4749

48-
fireEvent.keyDown(getByTestId('btn'), {which: 13})
50+
fireEvent.keyDown(screen.getByRole('button'), {key: 'Enter'})
4951
expect(cb).toBeCalledTimes(1)
5052
})
5153

5254
it(`should fire click event once for divs on click`, () => {
5355
const cb = jest.fn()
54-
const {getByTestId} = render(
56+
render(
5557
<Button>
56-
<div onClick={cb} data-testid="btn" />
58+
<div onClick={cb} />
5759
</Button>
5860
)
5961

60-
fireEvent.click(getByTestId('btn'))
62+
fireEvent.click(screen.getByRole('button'))
6163
expect(cb).toBeCalledTimes(0)
62-
fireEvent.mouseDown(getByTestId('btn'))
63-
fireEvent.click(getByTestId('btn'))
64+
userEvent.click(screen.getByRole('button'))
6465
expect(cb).toBeCalledTimes(1)
6566
})
6667

6768
it(`should fire click event once for divs on space`, () => {
6869
const cb = jest.fn()
69-
const {getByTestId} = render(
70+
render(
7071
<Button>
71-
<div onClick={cb} data-testid="btn" />
72+
<div onClick={cb} />
7273
</Button>
7374
)
7475

75-
fireEvent.keyDown(getByTestId('btn'), {which: 32})
76+
fireEvent.keyDown(screen.getByRole('button'), {key: ' '})
7677
expect(cb).toBeCalledTimes(1)
7778
})
7879

7980
it(`should fire click event once for divs on enter`, () => {
8081
const cb = jest.fn()
81-
const {getByTestId} = render(
82+
render(
8283
<Button>
83-
<div onClick={cb} data-testid="btn" />
84+
<div onClick={cb} />
8485
</Button>
8586
)
8687

87-
fireEvent.keyDown(getByTestId('btn'), {which: 13})
88-
expect(cb).toBeCalledTimes(1)
89-
})
90-
91-
it(`should fire user-defined onMouseDown`, () => {
92-
const cb = jest.fn()
93-
const {getByTestId} = render(
94-
<Button>
95-
<div onMouseDown={cb} data-testid="btn" />
96-
</Button>
97-
)
98-
99-
fireEvent.mouseDown(getByTestId('btn'))
88+
fireEvent.keyDown(screen.getByRole('button'), {key: 'Enter'})
10089
expect(cb).toBeCalledTimes(1)
10190
})
10291

@@ -114,7 +103,7 @@ describe('<Button>', () => {
114103
expect(
115104
render(
116105
<Button>
117-
<div role="menu" tabIndex={-1} />
106+
<div role='menu' tabIndex={-1} />
118107
</Button>
119108
).asFragment()
120109
).toMatchSnapshot('role=menu, tabIndex=-1')

src/index.tsx

+36-39
Original file line numberDiff line numberDiff line change
@@ -1,53 +1,50 @@
11
import * as React from 'react'
2-
import {useKeycodes} from '@accessible/use-keycode'
2+
import useKey from '@accessible/use-key'
3+
import useEvent from '@react-hook/event'
34
import useMergedRef from '@react-hook/merged-ref'
45

5-
export interface ButtonProps {
6-
children: JSX.Element | React.ReactElement
6+
export function useA11yButton<
7+
T extends HTMLElement,
8+
E extends React.MouseEvent<T, MouseEvent>
9+
>(target: React.RefObject<T> | T | null, onClick: (event: E) => any) {
10+
const clickedMouse = React.useRef(false)
11+
const setClickedMouse = () => (clickedMouse.current = true)
12+
useEvent(target, 'touchstart', setClickedMouse)
13+
useEvent(target, 'mousedown', setClickedMouse)
14+
// @ts-expect-error
15+
useKey(target, {
16+
Enter: onClick,
17+
' ': onClick,
18+
})
19+
20+
return {
21+
onClick: (event: E) => {
22+
// Only fire onClick if the keyboard was not used to initiate the click
23+
clickedMouse.current && onClick(event)
24+
clickedMouse.current = false
25+
},
26+
role: 'button',
27+
tabIndex: 0,
28+
} as const
729
}
8-
// eslint-disable-next-line @typescript-eslint/no-empty-function
9-
const noop = () => {}
1030

11-
const Button: React.FC<ButtonProps> = ({children}) => {
31+
export const Button = ({children}: ButtonProps) => {
32+
const ref = React.useRef(null)
1233
const {props} = children
13-
// Tracking the pressed value ensures that the onClick event won't fire
14-
// twice when the child component is an actual <button> element.
15-
const clicked = React.useRef<boolean>(false)
16-
const onClick = props.onClick || noop
34+
const {onClick, role, tabIndex} = useA11yButton(ref, props.onClick)
1735

1836
return React.cloneElement(children, {
19-
role: props.hasOwnProperty('role') ? props.role : 'button',
20-
tabIndex: props.hasOwnProperty('tabIndex') ? props.tabIndex : 0,
21-
onTouchStart: (e: React.TouchEvent<HTMLElement>) => {
22-
// Resets the pressed variable when a user starts clicking w/ touch devices
23-
clicked.current = true
24-
props.onTouchStart?.(e)
25-
},
26-
onMouseDown: (e: React.MouseEvent<HTMLElement>) => {
27-
// Resets the pressed variable when a user starts clicking w/ the mouse
28-
clicked.current = true
29-
props.onMouseDown?.(e)
30-
},
31-
onClick: (e: React.MouseEvent<HTMLElement>) => {
32-
// Only fire onClick if the keyboard was not used to initiate the
33-
// click
34-
clicked.current && onClick(e)
35-
clicked.current = false
36-
},
37-
ref: useMergedRef(
38-
// @ts-ignore
39-
children.ref,
40-
useKeycodes({
41-
// enter
42-
13: onClick,
43-
// space bar
44-
32: onClick,
45-
})
46-
),
37+
onClick,
38+
role: props.hasOwnProperty('role') ? props.role : role,
39+
tabIndex: props.hasOwnProperty('tabIndex') ? props.tabIndex : tabIndex,
40+
// @ts-expect-error
41+
ref: useMergedRef(ref, children.ref),
4742
})
4843
}
4944

50-
export default Button
45+
export interface ButtonProps {
46+
children: JSX.Element | React.ReactElement
47+
}
5148

5249
/* istanbul ignore next */
5350
if (typeof process !== 'undefined' && process.env.NODE_ENV !== 'production') {

types/index.d.ts

+26-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,30 @@
11
import * as React from 'react'
2+
export declare function useA11yButton<T extends Window>(
3+
target: Window | null,
4+
onClick: (event: WindowEventMap['click']) => any
5+
): void
6+
export declare function useA11yButton<T extends Document>(
7+
target: Document | null,
8+
onClick: (event: DocumentEventMap['click']) => any
9+
): void
10+
export declare function useA11yButton<T extends HTMLElement>(
11+
target: React.RefObject<T> | T | null,
12+
onClick: (event: HTMLElementEventMap['click']) => any
13+
): void
14+
export declare const Button: {
15+
({children}: ButtonProps): React.ReactElement<
16+
any,
17+
| string
18+
| ((
19+
props: any
20+
) => React.ReactElement<
21+
any,
22+
string | any | (new (props: any) => React.Component<any, any, any>)
23+
> | null)
24+
| (new (props: any) => React.Component<any, any, any>)
25+
>
26+
displayName: string
27+
}
228
export interface ButtonProps {
329
children: JSX.Element | React.ReactElement
430
}
5-
declare const Button: React.FC<ButtonProps>
6-
export default Button

0 commit comments

Comments
 (0)