DEV Community

Cover image for Improving Accessibility - Typehead
hritickjaiswal
hritickjaiswal

Posted on

Improving Accessibility - Typehead

Accessibility isn't just a "nice-to-have" feature—it’s the difference between a usable tool and a digital brick for many users. This Typeahead component balances performance (debouncing), reliability (aborting race conditions), and strict ARIA compliance.

1. The Power of aria-activedescendant
Instead of manually moving focus between list items (which is a nightmare for screen readers), this component uses the aria-activedescendant pattern.

The Logic: The focus remains on the , while the aria-activedescendant attribute points to the ID of the currently "highlighted" list item.

Why it matters: It allows the user to keep typing while the screen reader announces the current selection in the list.

2. Concurrency & Race Conditions
When fetching suggestions over a network, speed varies. If a user types "Apple" and then quickly "Banana," the "Apple" request might finish after "Banana," overwriting your state with stale data.

The Fix: Using AbortController and a latestRequestRef.

How it works: Every time the search query changes, we abort the previous request and use a ref to ensure only the most recent fetch updates the state.

3. Smart Keyboard Ergonomics
A component is only as good as its keyboard support. This implementation handles:

ArrowUp/Down: Navigates the list with index wrapping (going from the last item back to the first).

Enter: Selects the active item and closes the menu.

Escape: Immediately dismisses the dropdown to prevent focus traps.

Automatic Scrolling: The useEffect with scrollIntoView({ block: "nearest" }) ensures that if you keyboard-navigate to an item off-screen, the list scrolls to show it.

4. Performance: The Custom useDebounce
We don't want to fire an API call for every single keystroke.

Implementation: The custom useDebounce hook delays the update of the search term.

The Result: If the user types "React" quickly, only one network request is fired instead of five.

5. Semantic Feedback
The use of aria-live="polite" and aria-busy ensures that users are notified when the list is loading or if an error occurs. Without these, a screen reader user would have no idea if the component is working or if the search failed.

import {
  useEffect,
  useRef,
  useState,
  type ChangeEvent,
  type FocusEvent,
  type KeyboardEvent,
  type MouseEvent,
} from "react";
import styles from "./style.module.css";

export interface OptionType {
  label: string;
  value: string;
  id: number;
}

interface TypeheadProps {
  label: string;
  options: Array<OptionType>;
  onSelect?: (obj: OptionType) => void;
  fetchSuggestions?: (
    query: string,
    signal: AbortSignal
  ) => Promise<OptionType[]>;
}

function useDebounce<T>(value: T, delay = 300): T {
  const [debouncedValue, setDebouncedValue] = useState(() => value);

  useEffect(() => {
    const timerid = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    return () => {
      clearTimeout(timerid);
    };
  }, [value, delay]);

  return debouncedValue;
}

function Typehead({
  label,
  options,
  onSelect,
  fetchSuggestions,
}: TypeheadProps) {
  const [show, setShow] = useState(false);
  const [input, setInput] = useState("");
  const [searchedItems, setSearchedItems] = useState<Array<OptionType>>([]);
  const [activeId, setActiveId] = useState<null | number>(null);
  const [selectedLabel, setSelectedLabel] = useState("");
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState("");
  const debouncedValue = useDebounce(input);

  const wrapperRef = useRef<HTMLDivElement>(null);
  const listRef = useRef<HTMLUListElement>(null);
  const latestRequestRef = useRef(0);

  function onChangeHandler(e: ChangeEvent<HTMLInputElement>) {
    setInput(e.target.value);
    setSelectedLabel("");
  }

  function onFocusHandler(e: FocusEvent<HTMLInputElement, Element>) {
    setShow(true);
  }

  function onBlurHandler(e: FocusEvent<HTMLInputElement, Element>) {
    setShow(false);
  }

  function onMouseDownHandler(
    e: MouseEvent<HTMLLIElement, globalThis.MouseEvent>,
    obj: OptionType
  ) {
    e.preventDefault();
    setInput("");
    setSelectedLabel(obj.label);
    if (typeof onSelect === "function") onSelect(obj);
    setActiveId(obj.id);
    setShow(false);
  }

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

    if (key === "Escape") {
      setShow(false);
    } else if (searchedItems.length > 0) {
      if (key === "ArrowUp" && activeId === null) {
        e.preventDefault();
        setActiveId(searchedItems[searchedItems.length - 1].id);
      } else if (key === "ArrowUp") {
        e.preventDefault();
        let index = searchedItems.findIndex((obj) => obj.id === activeId);

        index = index - 1 < 0 ? searchedItems.length - 1 : index - 1;

        setActiveId(searchedItems[index].id);
      } else if (key === "ArrowDown" && activeId === null) {
        e.preventDefault();
        setActiveId(searchedItems[0].id);
      } else if (key === "ArrowDown") {
        e.preventDefault();
        let index = searchedItems.findIndex((obj) => obj.id === activeId);

        index = index + 1 >= searchedItems.length ? 0 : index + 1;

        setActiveId(searchedItems[index].id);
      } else if (key === "Enter" && activeId !== null) {
        const obj = searchedItems.find((e) => e.id === activeId);

        if (obj) {
          setInput("");
          setSelectedLabel(obj.label);
          if (typeof onSelect === "function") onSelect(obj);
          setActiveId(obj.id);
          setShow(false);
        }
      }
    }
  }

  async function handleFetch(query: string, signal: AbortSignal) {
    if (typeof fetchSuggestions === "function") {
      const requestId = ++latestRequestRef.current;

      try {
        setLoading(true);
        setError("");

        const temp = await fetchSuggestions(query, signal);

        if (requestId === latestRequestRef.current) {
          setSearchedItems(temp);
        }
      } catch (error) {
        if (signal.aborted) return;

        console.error(error);
        setError("Error");
      } finally {
        setLoading(false);
      }
    }
  }

  useEffect(() => {
    let controller: AbortController;

    if (typeof fetchSuggestions === "function") {
      console.log("debouncedValue", debouncedValue);

      if (debouncedValue.length === 0) {
        setSearchedItems([]);
        return;
      }

      controller = new AbortController();

      handleFetch(debouncedValue, controller.signal);
    } else {
      if (debouncedValue.length === 0) {
        setSearchedItems(options);
      } else {
        const temp = options.filter((option) =>
          option.value.toLowerCase().includes(debouncedValue.toLowerCase())
        );

        setSearchedItems(temp);
        setShow(true);
      }
    }

    return () => {
      if (typeof fetchSuggestions === "function" && controller) {
        controller.abort();
      }
    };
  }, [debouncedValue, fetchSuggestions, options]);

  useEffect(() => {
    if (show) {
      try {
        for (const child of listRef.current?.children) {
          const id = child.getAttribute("data-id");

          if (Number(id) === activeId) {
            child.scrollIntoView({
              behavior: "auto", // "auto" provides the instant jump you want
              block: "nearest", // Prevents unnecessary shifting if already in view
              inline: "nearest", // Applies the same logic horizontally
            });
            return;
          }
        }
      } catch (error) {
        console.error(error);
      }
    }
  }, [activeId, show]);

  return (
    <div ref={wrapperRef} className={styles.wrapper}>
      <label className={styles.label} htmlFor={`${label}-dropdown`}>
        {label}
      </label>
      <input
        onChange={onChangeHandler}
        value={input || selectedLabel}
        className={styles.input}
        id={`${label}-dropdown`}
        onFocus={onFocusHandler}
        onBlur={onBlurHandler}
        aria-expanded={show}
        role="combobox"
        aria-activedescendant={
          show && activeId !== null && activeId >= 0
            ? `option-${activeId}`
            : undefined
        }
        onKeyDown={keyDownHandler}
        aria-controls={show ? "listbox" : undefined}
        aria-autocomplete="list"
      />

      {show ? (
        <ul
          id="listbox"
          role="listbox"
          ref={listRef}
          tabIndex={-1}
          className={styles.list}
        >
          {error.length > 0 ? (
            <li aria-busy="true" aria-live="polite" className={styles.listItem}>
              Error
            </li>
          ) : loading ? (
            <li aria-busy="true" aria-live="polite" className={styles.listItem}>
              Loading...
            </li>
          ) : searchedItems.length > 0 ? (
            <>
              {searchedItems.map((obj) => (
                <li
                  id={`option-${obj.id}`}
                  role="option"
                  aria-selected={activeId === obj.id}
                  onMouseDown={(e) => onMouseDownHandler(e, obj)}
                  className={`${styles.listItem} ${
                    activeId === obj.id ? styles.active : ""
                  }`}
                  key={obj.id}
                  data-id={obj.id}
                >
                  {obj.label}
                </li>
              ))}
            </>
          ) : (
            <li aria-live="polite" role="option" className={styles.listItem}>
              No filtered items found
            </li>
          )}
        </ul>
      ) : null}
    </div>
  );
}

export default Typehead;

Enter fullscreen mode Exit fullscreen mode

And let's not forget how much CSS is important for accessibility

.list {
  position: absolute;
  list-style: none;
  background-color: lightgray;
  display: flex;
  flex-direction: column;
  overflow-y: auto;
  max-height: 240px;
  width: 100%;
  transform: translateY(80px);
}

.listItem {
  padding: 0.5rem 0.25rem;
  cursor: pointer;
  color: #000;
}

.listItem.active {
  background-color: springgreen;
}

.listItem:hover {
  /* background-color: springgreen; */
  background-color: #a5e356;
}
Enter fullscreen mode Exit fullscreen mode

Top comments (0)