Better cross browser buttons

Since writing this post, I have managed to work out that this became an issue on Macs because of a software update that added this option in the settings, and defaulted it to unchecked.

MacOS Keyboard Shortcut settings

Which leaves us with an (open) question; should web devs override this MacOS behaviour because it is inconsistent with other operating systems? Or should we leave it to the default behaviour from the operating system, even if it seems (at first glance) to be more of an inconvenience?

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 given type="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! :)