Skip to content

Commit 9090a16

Browse files
committed
version 1
1 parent 32656db commit 9090a16

File tree

9 files changed

+248
-24
lines changed

9 files changed

+248
-24
lines changed

.eslintrc

+1-1
Original file line numberDiff line numberDiff line change
@@ -53,4 +53,4 @@
5353
}
5454
}
5555
]
56-
}
56+
}

.prettierrc

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,4 @@
44
"semi": false,
55
"singleQuote": true,
66
"bracketSpacing": false
7-
}
7+
}

README.md

+44-6
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<hr>
22
<div align="center">
33
<h1 align="center">
4-
@accessible/button
4+
&lt;Button&gt;
55
</h1>
66
</div>
77

@@ -29,21 +29,59 @@
2929
<pre align="center">npm i @accessible/button</pre>
3030
<hr>
3131

32-
An accessible button component for React that provides interop between real `<button>` elements and fake ones, e.g. `<div role='button'>`
32+
An accessible button component for React that provides interop between real `<button>` elements and fake ones, e.g. `<div role='button'>`.
33+
To do so, this component attaches the `onClick` handler from its child component to the keyboard
34+
events for `space` and `enter`. It also adds `role='button'` and `tabIndex={0}` properties, though
35+
this behavior can be overridden by providing those props to the child component e.g. `<Button><div tabIndex={-1}></Button>`.
36+
37+
## Why does this exist?
38+
39+
In designing accessible libraries, we just don't know if our users are going to do the right thing
40+
i.e. using a `<button>` for buttons, rather than a `<div>`, `<span>`, or `<a>`. This component
41+
provides interoperability between `<button>` elements and those faux button elements.
3342

3443
## Quick Start
3544

45+
[Check out the example on CodeSandbox](https://codesandbox.io/s/accessiblebutton-example-spjh2)
46+
3647
```jsx harmony
37-
import _ from '@accessible/button'
48+
import Button from '@accessible/button'
49+
50+
const ComponentA = () => (
51+
// Adds `space` and `enter` keydown handlers to the div,
52+
// also adds role='button' and tabIndex='0', both
53+
// of which can be overridden by providing those
54+
// props on your <div>
55+
<Button>
56+
<div onClick={console.log} />
57+
</Button>
58+
// <div role='button' tabindex='0'/>
59+
)
60+
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+
)
3876
```
3977

4078
## API
4179

4280
### Props
4381

44-
| Prop | Type | Default | Required? | Description |
45-
| ---- | ---- | ------- | --------- | ----------- |
46-
| | | | | |
82+
| Prop | Type | Default | Required? | Description |
83+
| -------- | -------------------- | ----------- | --------- | ---------------------------------------------------------------------------------------------------- |
84+
| children | `React.ReactElement` | `undefined` | Yes | The component you want to turn into a button that handles focus and `space`, `enter` keydown events. |
4785

4886
## LICENSE
4987

package.json

+4-1
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,10 @@
8484
"ts-jest": "latest",
8585
"typescript": "latest"
8686
},
87-
"dependencies": {},
87+
"dependencies": {
88+
"@accessible/use-keycode": "^2.0.0",
89+
"@react-hook/merged-ref": "^1.0.8"
90+
},
8891
"peerDependencies": {
8992
"prop-types": ">=15.6",
9093
"react": ">=16.8",

src/__snapshots__/index.test.tsx.snap

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
// Jest Snapshot v1, https://goo.gl/fbAQLP
2+
3+
exports[`<Button> should add accessible roles: role=button, tabIndex=0 1`] = `
4+
<DocumentFragment>
5+
<div
6+
role="button"
7+
tabindex="0"
8+
/>
9+
</DocumentFragment>
10+
`;
11+
12+
exports[`<Button> should allow roles to be overridden: role=menu, tabIndex=-1 1`] = `
13+
<DocumentFragment>
14+
<div
15+
role="menu"
16+
tabindex="-1"
17+
/>
18+
</DocumentFragment>
19+
`;

src/index.test.tsx

+107-6
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,110 @@
11
/* jest */
2-
// import React from 'react'
3-
// import {renderHook} from '@testing-library/react-hooks'
4-
// import {render} from '@testing-library/react'
5-
const hello = world => `hello ${world}`
2+
import React from 'react'
3+
import {render, fireEvent} from '@testing-library/react'
4+
import Button from './index'
65

7-
test('passes', () => {
8-
expect(hello('world')).toMatchSnapshot()
6+
describe('<Button>', () => {
7+
it(`should fire click event once for buttons on click`, () => {
8+
const cb = jest.fn()
9+
const {getByTestId} = render(
10+
<Button>
11+
<button onClick={cb} data-testid="btn" />
12+
</Button>
13+
)
14+
15+
fireEvent.click(getByTestId('btn'))
16+
expect(cb).toBeCalledTimes(1)
17+
})
18+
19+
it(`should fire click event once for buttons on space`, () => {
20+
const cb = jest.fn()
21+
const {getByTestId} = render(
22+
<Button>
23+
<button onClick={cb} data-testid="btn" />
24+
</Button>
25+
)
26+
27+
fireEvent.keyDown(getByTestId('btn'), {which: 32})
28+
expect(cb).toBeCalledTimes(1)
29+
})
30+
31+
it(`should fire click event once for buttons on enter`, () => {
32+
const cb = jest.fn()
33+
const {getByTestId} = render(
34+
<Button>
35+
<button onClick={cb} data-testid="btn" />
36+
</Button>
37+
)
38+
39+
fireEvent.keyDown(getByTestId('btn'), {which: 13})
40+
expect(cb).toBeCalledTimes(1)
41+
})
42+
43+
it(`should fire click event once for divs on click`, () => {
44+
const cb = jest.fn()
45+
const {getByTestId} = render(
46+
<Button>
47+
<div onClick={cb} data-testid="btn" />
48+
</Button>
49+
)
50+
51+
fireEvent.click(getByTestId('btn'))
52+
expect(cb).toBeCalledTimes(1)
53+
})
54+
55+
it(`should fire click event once for divs on space`, () => {
56+
const cb = jest.fn()
57+
const {getByTestId} = render(
58+
<Button>
59+
<div onClick={cb} data-testid="btn" />
60+
</Button>
61+
)
62+
63+
fireEvent.keyDown(getByTestId('btn'), {which: 32})
64+
expect(cb).toBeCalledTimes(1)
65+
})
66+
67+
it(`should fire click event once for divs on enter`, () => {
68+
const cb = jest.fn()
69+
const {getByTestId} = render(
70+
<Button>
71+
<div onClick={cb} data-testid="btn" />
72+
</Button>
73+
)
74+
75+
fireEvent.keyDown(getByTestId('btn'), {which: 13})
76+
expect(cb).toBeCalledTimes(1)
77+
})
78+
79+
it(`should fire user-defined onMouseDown`, () => {
80+
const cb = jest.fn()
81+
const {getByTestId} = render(
82+
<Button>
83+
<div onMouseDown={cb} data-testid="btn" />
84+
</Button>
85+
)
86+
87+
fireEvent.mouseDown(getByTestId('btn'))
88+
expect(cb).toBeCalledTimes(1)
89+
})
90+
91+
it(`should add accessible roles`, () => {
92+
expect(
93+
render(
94+
<Button>
95+
<div />
96+
</Button>
97+
).asFragment()
98+
).toMatchSnapshot('role=button, tabIndex=0')
99+
})
100+
101+
it(`should allow roles to be overridden`, () => {
102+
expect(
103+
render(
104+
<Button>
105+
<div role="menu" tabIndex={-1} />
106+
</Button>
107+
).asFragment()
108+
).toMatchSnapshot('role=menu, tabIndex=-1')
109+
})
9110
})

src/index.tsx

+53-3
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,53 @@
1-
// import React from 'react'
2-
const Component = (): string => 'Hello world'
3-
export default Component
1+
import React, {cloneElement, useRef, useCallback} from 'react'
2+
import {useKeycodes} from '@accessible/use-keycode'
3+
import useMergedRef from '@react-hook/merged-ref'
4+
5+
export interface ButtonProps {
6+
children: JSX.Element | React.ReactElement
7+
}
8+
9+
const Button: React.FC<ButtonProps> = ({children}) => {
10+
const {props} = children
11+
// Tracking the pressed value ensures that the onClick event won't fire
12+
// twice when the child component is an actual <button> element.
13+
const pressed = useRef<boolean>(false)
14+
const handleInterop = useCallback(
15+
(e: KeyboardEvent) => {
16+
props.onClick?.(e)
17+
pressed.current = true
18+
},
19+
[props.onClick]
20+
)
21+
22+
return cloneElement(children, {
23+
role: props.hasOwnProperty('role') ? props.role : 'button',
24+
tabIndex: props.hasOwnProperty('tabIndex') ? props.tabIndex : 0,
25+
onMouseDown: e => {
26+
// Resets the pressed variable when a user starts clicking w/ the mouse
27+
pressed.current = false
28+
props.onMouseDown?.(e)
29+
},
30+
onClick: e => {
31+
// Only fire onClick if the keyboard was not used to initiate the
32+
// click
33+
!pressed.current && props.onClick?.(e)
34+
},
35+
ref: useMergedRef(
36+
// @ts-ignore
37+
children.ref,
38+
useKeycodes({
39+
// enter
40+
13: handleInterop,
41+
// space bar
42+
32: handleInterop,
43+
})
44+
),
45+
})
46+
}
47+
48+
export default Button
49+
50+
/* istanbul ignore next */
51+
if (typeof process !== 'undefined' && process.env.NODE_ENV !== 'production') {
52+
Button.displayName = 'AccessibleButton'
53+
}

tsconfig.json

+2-6
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,7 @@
88
],
99
"compilerOptions": {
1010
"target": "es5",
11-
"lib": [
12-
"esnext",
13-
"dom",
14-
"dom.iterable"
15-
],
11+
"lib": ["esnext", "dom", "dom.iterable"],
1612
"jsx": "react",
1713
"moduleResolution": "node",
1814
"strictNullChecks": true,
@@ -24,4 +20,4 @@
2420
"noFallthroughCasesInSwitch": true,
2521
"noEmitOnError": true
2622
}
27-
}
23+
}

yarn.lock

+17
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@
22
# yarn lockfile v1
33

44

5+
"@accessible/use-keycode@^2.0.0":
6+
version "2.0.0"
7+
resolved "https://registry.yarnpkg.com/@accessible/use-keycode/-/use-keycode-2.0.0.tgz#9fbaf02a9b345ffd86a62f231c30789c3860e237"
8+
integrity sha512-pvcrkiMlRaCmh1luRlfSxdDKjElQiPNvaBmY1cibRopgBYXN94cqJXAequOn3cFWxsbhLVxfbdq+x/ocp5+q3w==
9+
dependencies:
10+
"@react-hook/passive-layout-effect" "^1.0.2"
11+
512
"@babel/cli@latest":
613
version "7.7.7"
714
resolved "https://registry.yarnpkg.com/@babel/cli/-/cli-7.7.7.tgz#56849acbf81d1a970dd3d1b3097c8ebf5da3f534"
@@ -1075,6 +1082,16 @@
10751082
"@nodelib/fs.scandir" "2.1.3"
10761083
fastq "^1.6.0"
10771084

1085+
"@react-hook/merged-ref@^1.0.8":
1086+
version "1.0.8"
1087+
resolved "https://registry.yarnpkg.com/@react-hook/merged-ref/-/merged-ref-1.0.8.tgz#146deba739df2724f76f4e8a29adbae0e1724994"
1088+
integrity sha512-ae3HwS/zCDZqoeLliqBPfHc5DeX3bhAR2rsZ2JpDm68c6Lvx05Hs5SOQIQSaRQIsGzFTinoI2TO7LyOcD8s23g==
1089+
1090+
"@react-hook/passive-layout-effect@^1.0.2":
1091+
version "1.0.2"
1092+
resolved "https://registry.yarnpkg.com/@react-hook/passive-layout-effect/-/passive-layout-effect-1.0.2.tgz#a0138ece2a86d53d9ba947818715076a9aed6ba7"
1093+
integrity sha512-el1V2Calc5/HzUbz/Gkit9yeI/ypoap46Eo9u4EOg6+pnSQkUwU0faM22+Owp7+0cbz30fT9zFLrXy4JDuBSBQ==
1094+
10781095
"@samverschueren/stream-to-observable@^0.3.0":
10791096
version "0.3.0"
10801097
resolved "https://registry.yarnpkg.com/@samverschueren/stream-to-observable/-/stream-to-observable-0.3.0.tgz#ecdf48d532c58ea477acfcab80348424f8d0662f"

0 commit comments

Comments
 (0)