DEV Community

Cover image for Building an Accessible Angular Combobox: What the ARIA Checklist Doesn't Tell You
Rawia Ahmed
Rawia Ahmed

Posted on

Building an Accessible Angular Combobox: What the ARIA Checklist Doesn't Tell You

Your combobox passes the audit. It still doesn't work with a screen reader.

I've been building frontends for 13 years, and I've used a lot of open source along the way. This year I wanted to give something back. So I decided my first contribution would be small, and I'd do it properly.

I picked a combobox. It looks like the simplest component there is: an input, a list, pick one. It isn't.

Most component libraries treat accessibility as a checklist. Add role="listbox", add aria-expanded, run an automated audit, get a green tick, ship. And the result can still be unusable with a screen reader. The hard parts of a combobox aren't attributes. They're about focus, what gets announced and when, and what happens when the list changes while someone's typing.

This post goes through the decisions I made building ngx-generic-combobox, an Angular 22 component built on Signals. Most of them apply to any framework.

Keyboard navigation in the combobox: arrow keys move through countries while focus stays in the input, navigation stops at the last option, Escape closes the list, typing

Arrow keys, Escape, filtering and Enter. The panel on the right shows what a screen reader hears.

1. Focus never leaves the input

This is the one most implementations get wrong.

When a user arrows through the options, the obvious move is to put real DOM focus on each option. Screen readers are happy. But now the user can't type, because focus is no longer in the input. And typing is the whole point of a combobox.

The fix is virtual focus. DOM focus stays in the input, and aria-activedescendant tells assistive technology which option is active:

<input
  type="text"
  role="combobox"
  [attr.aria-expanded]="isOpen()"
  [attr.aria-controls]="listboxId"
  [attr.aria-activedescendant]="activeDescendantId()"
  aria-autocomplete="list"
/>
Enter fullscreen mode Exit fullscreen mode

Note that role="combobox" sits on the input itself, not on a wrapper div. ARIA 1.2 moved it there so the element that has focus is the element carrying the state.

There's a detail here that's easy to miss. When nothing is active, aria-activedescendant should be absent, not an empty string:

protected readonly activeDescendantId = computed(() => {
  const i = this.activeIndex();
  return this.isOpen() && i >= 0 ? this.optionId(i) : null;
});
Enter fullscreen mode Exit fullscreen mode

Returning null makes Angular remove the attribute. An empty string is a reference to nothing, and some screen readers react to that by announcing nothing at all.

Virtual focus has a side effect I only found while writing a Storybook story with a long list. Because DOM focus never moves into the list, the browser never scrolls the active option into view. Arrow down far enough and you're selecting options you can't see. So an effect handles it:

effect(() => {
  const index = this.activeIndex();
  if (!this.isOpen() || index < 0) return;
  const option = this.listbox()?.nativeElement.children[index] as HTMLElement | undefined;
  option?.scrollIntoView?.({ block: 'nearest' });
});
Enter fullscreen mode Exit fullscreen mode

block: 'nearest' scrolls the minimum needed, and does nothing when the option is already visible. Without it, the list jumps on every keypress.

2. Announce the count, not the list

When you type into a combobox, the list filters. A sighted user sees that happen. A screen reader user gets silence.

So there's a separate live region that announces how many results are left:

<div class="ngx-combobox__sr-only" role="status" aria-live="polite">{{ announcement() }}</div>
Enter fullscreen mode Exit fullscreen mode
protected readonly announcement = computed(() => {
  if (!this.isOpen()) return '';
  const n = this.filtered().length;
  if (n === 0) return this.emptyMessage();
  return n === 1 ? '1 result available' : `${n} results available`;
});
Enter fullscreen mode Exit fullscreen mode

It's polite on purpose. Reading the whole list out would interrupt the user mid-word. The count is enough to know whether to keep typing.

And the "No results" row in the list is deliberately not role="option". You can't select it, so it shouldn't be counted as an option.

3. Small keyboard decisions that matter

None of these is big on its own. Together they're the difference between a component that works and one that's just technically compliant.

Escape does two different things. The first press closes the list and keeps what you typed. Only a second press, with the list already closed, clears the field. If one press did both, a single slip of the finger would wipe out someone's input.

case 'Escape':
  event.preventDefault();
  if (this.isOpen()) {
    this.close();
  } else {
    this.query.set('');
    this.value.set(undefined);
  }
  break;
Enter fullscreen mode Exit fullscreen mode

Navigation stops at the ends. No wrapping from the last option back to the first. In a filtered list, wrapping makes it really easy to go past the option you wanted without noticing.

Disabled options stay in the DOM. Arrow keys skip them and you can't select them, but they're still rendered and counted. If the number of options changed depending on how you got there, the announced count would stop making sense.

Home and End belong to the text cursor while the list is closed. They only jump between options once it's open. I know this one well, because my first test run failed on it. The component was right. The test was wrong.

Blur restores the selected label. If you type half a word and tab away, the field goes back to what you actually picked. A half-typed query never pretends to be a choice.

Tab never gets trapped. It closes the list and moves on, and doesn't quietly select anything.

4. Visual accessibility too

Screen readers aren't the whole story.

The active option has a background colour and an inset border, because colour alone fails WCAG 1.4.1. Windows High Contrast mode drops backgrounds entirely, so there's a forced-colors block that swaps in a system-coloured border:

@media (forced-colors: active) {
  .ngx-combobox__option--active {
    border: 2px solid Highlight;
  }
}
Enter fullscreen mode Exit fullscreen mode

And prefers-reduced-motion turns off the chevron animation.

5. Test the contract, not the implementation

The component has 20 tests, and they check the accessibility contract: the label is associated, aria-expanded is right, aria-activedescendant is there when it should be and gone when it shouldn't, only one option is aria-selected, the live region says the right thing, and Escape behaves both ways.

Here's why that matters. A test that says "clicking an option sets the value" will keep passing after a refactor that breaks the component for screen reader users. A test that says "aria-activedescendant points at the active option" won't.

I've led frontend teams for years, and this is the same rule I'd set for any test suite: test what the user depends on, not how the code happens to do it.

What isn't done yet

I'd rather be honest about this than oversell it.

  • It hasn't been tested with real screen readers yet. The tests check the ARIA contract, which is necessary but not enough. NVDA, JAWS and VoiceOver disagree with each other in ways only manual testing finds. That's next.
  • No automated axe check in CI yet.
  • No multi-select, option groups or async loading. Each of those changes the ARIA pattern rather than just adding to it, so I'd rather build them properly than fake them.

Try it

npm install ngx-generic-combobox
Enter fullscreen mode Exit fullscreen mode

The source is on GitHub, and every decision in this post has a comment in the code explaining why.

If you use a screen reader and try it, I'd really like to hear what breaks.

Top comments (0)