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;
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;
}
Top comments (0)