FieldNotesFORRESTBLADE.COM ↗

2019-11-155 MIN[react][accessibility][tutorial]

a button is not a div

A bug report came into the design system team a few weeks ago that said, in full, "dropdown does not work." It worked fine on my machine. It worked fine on the reporter's machine too, but this guy wasn't using a mouse. He's a JAWS user in another part of the company, the dropdown was a styled div with an onClick, and as far as his screen reader was concerned it did not exist.

I've been building components for our internal design system since spring, we're rebuilding the Bootstrap pieces as React components that follow our standards, and this changed how thought about writing interfaces.

the component everyone ships

// please don't
function Dropdown({ label, items }) {
  const [open, setOpen] = useState(false);
  return (
    <div className="dropdown">
      <div className="dropdown-trigger" onClick={() => setOpen(!open)}>
        {label}
      </div>
      {open && (
        <div className="dropdown-menu">
          {items.map((item) => (
            <div key={item.id} className="dropdown-item" onClick={item.action}>
              {item.text}
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

It looks right, it clicks right, it ships. Here is everything it can't do. You can't Tab to it, because divs aren't focusable. You can't open it with Enter or Space, because divs don't have activation behavior. A screen reader announces the trigger as plain text, so there's no hint it does anything. Arrow keys do nothing. Escape does nothing. To a keyboard user this component is a label. To a screen reader user it's not even that.

what a button gives you for free

Change the trigger div to <button> and before you write another line: it's in the tab order, it activates on Enter and Space, it announces as a button, focus styles exist, and disabled works. That's five behaviors for six characters of HTML. The single highest-value accessibility improvement in most codebases is replacing clickable divs with buttons, and it costs nothing except deleting the CSS you wrote to make divs look clickable in the first place.

The rule I now write components by: use the element that already does the job, and reach for ARIA only for the parts HTML doesn't cover.

ARIA is a contract

For a dropdown menu, the parts HTML doesn't cover are real, and the WAI-ARIA Authoring Practices document (read it, it's the actual manual) defines the pattern. The trigger gets aria-haspopup="true" and aria-expanded. The menu gets role="menu", the items get role="menuitem". But here's the part that took me longest to internalize: those attributes are promises, not decoration. The moment you say role="menu", a screen reader tells its user "use arrow keys to navigate," because that's what menus do. If you didn't implement arrow keys, you didn't add accessibility, you lied about it. Half-applied ARIA is worse than none, because plain markup at least doesn't make promises it can't keep.

the contract, written down

key            when the menu is closed          when the menu is open
---            -----------------------          ---------------------
Enter/Space    opens, focus to first item       activates focused item
ArrowDown      opens, focus to first item       moves focus down, wraps
ArrowUp        opens, focus to last item        moves focus up, wraps
Escape         nothing                          closes, focus returns to trigger
Tab            leaves the component             closes, moves on
Home / End     nothing                          first / last item

building it

// MenuButton.js
import React, { useEffect, useRef, useState } from 'react';

export function MenuButton({ label, items }) {
  const [open, setOpen] = useState(false);
  const [activeIndex, setActiveIndex] = useState(0);
  const triggerRef = useRef(null);
  const menuRef = useRef(null);
  const itemRefs = useRef([]);

  // Focus follows activeIndex while open.
  useEffect(() => {
    if (open && itemRefs.current[activeIndex]) {
      itemRefs.current[activeIndex].focus();
    }
  }, [open, activeIndex]);

  // A click anywhere else closes the menu.
  useEffect(() => {
    if (!open) return;
    const onOutside = (event) => {
      if (menuRef.current && !menuRef.current.contains(event.target) &&
          !triggerRef.current.contains(event.target)) {
        setOpen(false);
      }
    };
    document.addEventListener('mousedown', onOutside);
    return () => document.removeEventListener('mousedown', onOutside);
  }, [open]);

  const openAt = (index) => {
    setActiveIndex(index);
    setOpen(true);
  };

  const close = () => {
    setOpen(false);
    triggerRef.current.focus();
  };

  const onTriggerKeyDown = (event) => {
    if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') {
      event.preventDefault();
      openAt(0);
    } else if (event.key === 'ArrowUp') {
      event.preventDefault();
      openAt(items.length - 1);
    }
  };

  const onMenuKeyDown = (event) => {
    if (event.key === 'ArrowDown') {
      event.preventDefault();
      setActiveIndex((i) => (i + 1) % items.length);
    } else if (event.key === 'ArrowUp') {
      event.preventDefault();
      setActiveIndex((i) => (i - 1 + items.length) % items.length);
    } else if (event.key === 'Home') {
      event.preventDefault();
      setActiveIndex(0);
    } else if (event.key === 'End') {
      event.preventDefault();
      setActiveIndex(items.length - 1);
    } else if (event.key === 'Escape') {
      close();
    } else if (event.key === 'Tab') {
      setOpen(false);
    }
  };

  return (
    <div className="menu-button">
      <button
        ref={triggerRef}
        aria-haspopup="true"
        aria-expanded={open}
        onClick={() => (open ? close() : openAt(0))}
        onKeyDown={onTriggerKeyDown}
      >
        {label}
      </button>
      {open && (
        <ul role="menu" ref={menuRef} onKeyDown={onMenuKeyDown}>
          {items.map((item, index) => (
            <li
              key={item.id}
              role="menuitem"
              tabIndex={-1}
              ref={(el) => (itemRefs.current[index] = el)}
              onClick={() => { item.action(); close(); }}
            >
              {item.text}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

The items are tabIndex={-1}, focusable by script but not in the tab order, because a menu is one tab stop and arrows move within it. Escape returns focus to the trigger, which is the difference between a menu closing and a keyboard user being dumped at the top of the page. And the whole thing is still just React state and refs, there's no accessibility library, it's maybe forty extra lines over the div version.

We did look at what exists before building our own. Reach UI does this correctly and Downshift is excellent for comboboxes, and if your constraints allow them, use them. Ours don't, we need our own markup, our own styling hooks, and IE11 support, because the people using these internal tools don't get to pick their browser.

testing it like the bug reporter

Keyboard first, it's free: unplug your mouse, or just don't touch it, and operate the whole component with the table above open. Then a screen reader. NVDA costs nothing on Windows, and JAWS is what our actual users run, so the component isn't done until both announce it properly: "menu button, collapsed" on the trigger, item count and position inside the menu, and silence when it closes because focus went somewhere sensible.

why this belongs in a design system

Getting this right cost me about three days including the reading, the testing, and the arguing about focus styles. That's three days no other team at this company ever has to spend, and more to the point, it's three days most teams would never have spent, they'd have shipped the div. A design system means the accessible version and the easy version are the same component. The dropdown that started all this is fixed, the bug reporter closed the ticket.