DEV Community

Cover image for Improving Accessibility – Dropdown
hritickjaiswal
hritickjaiswal

Posted on

Improving Accessibility – Dropdown

I recently set out to improve my accessibility skills by doing something practical:
building accessible UI primitives from scratch.

And so today I targeted custom Dropdown / Select (not the native )

Why a Custom Dropdown?

Because it is deceptively simple.

Visually, it’s trivial.
Behaviorally and accessibly, it’s one of the most commonly broken components on the web.

If you can build an accessible custom select, you are forced to understand:

  • Focus management
  • Keyboard-first interaction
  • ARIA behavior, not just attributes
  • Event scoping and containment

First Iteration Requirements

  • Keyboard navigation
  • ARIA Must Match the Actual Interaction Model
  • Keyboard Handling Must Be Scoped

Implementation

import { useEffect, useRef, useState } from "react";
import styles from "./style.module.css";

interface OptionType {
  label: string;
  value: string;
}

interface DropdownProps {
  value: string;
  options: Array<OptionType>;
  onChangeHandler: (val: string) => void;
}

function Dropdown({ onChangeHandler, options, value }: DropdownProps) {
  const [show, setShow] = useState(false);
  const [activeIndex, setActiveIndex] = useState(-1);

  const wrapperRef = useRef<HTMLDivElement>(null);

  function handleSelectBtnClick() {
    if (!show) {
      setActiveIndex(options.findIndex((option) => option.value === value));
    }

    setShow((prev) => !prev);
  }

  function handleOptionClick(e: MouseEvent, val: string) {
    e.preventDefault();

    onChangeHandler(val);
    setShow(false);
  }

  function blurHandler() {
    setShow(false);
  }

  useEffect(() => {
    const keyDownHandler = (e: KeyboardEvent) => {
      const { key } = e;

      if (key === "ArrowUp") {
        setActiveIndex((prev) => {
          return prev - 1 >= 0 ? prev - 1 : 0;
        });
      } else if (key === "ArrowDown") {
        setActiveIndex((prev) => {
          return prev + 1 < options.length ? prev + 1 : options.length - 1;
        });
      } else if (key === "Enter" && activeIndex >= 0) {
        onChangeHandler(options[activeIndex].value);
      } else if (key === "Escape") {
        setShow(false);
      } else if (
        key.toLowerCase().charCodeAt(0) >= 97 &&
        key.toLowerCase().charCodeAt(0) <= 122
      ) {
        const index = options.findIndex(
          (option) => option.value[0].toLowerCase() === key.toLowerCase()
        );

        if (index >= 0) {
          setActiveIndex(index);
        }
      }
    };

    if (show) {
      wrapperRef.current?.addEventListener("keydown", keyDownHandler);
    }

    return () => {
      if (show)
        wrapperRef.current?.removeEventListener("keydown", keyDownHandler);
    };
  }, [show, options, activeIndex, onChangeHandler]);

  return (
    <div ref={wrapperRef} className={styles.wrapper}>
      <button
        onClick={handleSelectBtnClick}
        className={styles.selectBtn}
        onBlur={blurHandler}
        aria-haspopup="listbox"
        aria-expanded={show}
        aria-controls="dropdown-listbox"
        aria-activedescendant={
          show && activeIndex >= 0 ? `option-${activeIndex}` : undefined
        }
      >
        {value ? value : "Select an option"}
      </button>

      <ul
        role="listbox"
        id="dropdown-listbox"
        className={`${styles.optionList} ${!show ? styles.hidden : ""}`}
      >
        {options.map(({ label, value }, i) => (
          <li
            id={`option-${i}`}
            onMouseDown={(e) => handleOptionClick(e, value)}
            className={`${styles.listItem} ${
              i === activeIndex ? styles.active : ""
            }`}
            key={value}
            aria-selected={i === activeIndex}
            role="option"
          >
            {label}
          </li>
        ))}
      </ul>
    </div>
  );
}

export default Dropdown;
Enter fullscreen mode Exit fullscreen mode

Styles

.listItem.active {
  background-color: green;
  color: #fff;
  font-weight: 600;
}

.selectBtn:focus {
  outline-offset: 4px;
}

.hidden {
  display: none;
}
Enter fullscreen mode Exit fullscreen mode

Key Learnings

This was the most practical, hands-on learning.

The problem
When clicking an option:
blur fires
Then click fires

And the dropdown closes before selection happens

Visually, everything looks fine. Functionally, selection can fail.

The fix

Understanding event priority changed everything:

onMouseDown fires before blur

onClick fires after blur

By handling selection on onMouseDown, I was able to:

Capture the user’s intent
Prevent premature close
Preserve correct keyboard and mouse behavior

This wasn’t about hacks — it was about understanding how the browser processes events.

Top comments (0)