The HTML button tag is a mess

What happens when you click a button?
You would hope it would:
- fire its click event listeners
- receive the browser focus
- submit its parent
<form>
if it was giventype="submit"
What if you don't use a pointing device?
You would hope they could:
- focus the button with the keyboard
- simulate a button click (as specified above) by pressing either Enter or Space
Awesome!
Except... the <button>
tag doesn't do this.
At least, not consistently across all the major browsers.
Here's a link to a breakdown of different browsers input compatibility inconsistencies. There's a lot there, because there's a lot of inconsistencies...
So how do we do it?
I explored a few alternatives and came to realize that the least worst solution
is to use <div tabindex="0">
, with event listeners to "polyfill" (read: wrap
with secret functions) the default behavior of the <button>
tag.
Accessible buttons aren't particularly sexy, so here's some example React code with some comments that highlight the main ideas behind an abstracted solution:
const React, { Fragment, useRef } from 'react';
const { number, string, func, bool } from 'prop-types';
const Button = ({
onClick: _onClick,
onKeyDown: _onKeyDown,
tabIndex,
type,
...props
}) => {
const hiddenInputEl = useRef(null);
// wrap click event to not fire the callback when disabled
const onClick = ev => {
if (!disabled) {
_onClick(ev);
// clicks the hidden input, if it has one - e.g. to trigger a form submit
if (hiddenInputEl.current && hiddenInputEl.current.click) {
hiddenInputEl.current.click();
}
}
};
// wrap keydown event to call onClick when Enter or Space is pressed
const onKeyDown = ev => {
const { preventDefault } = ev;
let preventedDefault = false;
ev.preventDefault = (...args) => {
preventedDefault = true;
preventDefault.apply(ev, args);
};
_onKeyDown(ev);
if (!preventedDefault && [' ', 'Enter'].includes(evt.keyCode)) {
_onClick(ev);
}
};
return (
<Fragment>
<span
onClick={onClick}
onKeyDown={onKeyDown}
tabIndex={tabIndex}
type={type}
{...props}
/>
{
(type === 'button')
? null
: (
// hidden input to polyfill type="submit" and type="reset" behavior
<input
{...props}
type={type}
ref={hiddenInputEl}
tabIndex={-1}
style={{
position: 'absolute',
height: 1,
width: 1,
opacity: 0,
overflow: 'hidden',
pointerEvents: 'none',
clip: 'rect(1px, 1px, 1px, 1px)'
}}
/>
)
}
</Fragment>
);
};
Button.defaultProps = {
tabIndex: 0,
role: "button",
type: "button",
onClick: () => {},
onKeyDown: () => {},
disabled: false
};
Button.propTypes = {
tabIndex: number.isRequired,
role: string.isRequired,
type: string.isRequired,
onClick: func.isRequired,
onKeyDown: func.isRequired,
disabled: bool.isRequired
};
If there's an error with the code, or you have some additional suggestions please reach out to me by email and I'll get back to you! :)