<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Rawia Ahmed</title>
    <description>The latest articles on DEV Community by Rawia Ahmed (@rawia_ahmed_7514e4af94630).</description>
    <link>https://dev.to/rawia_ahmed_7514e4af94630</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3779932%2F3836ad49-bf68-4113-99b0-3d22b01f9234.png</url>
      <title>DEV Community: Rawia Ahmed</title>
      <link>https://dev.to/rawia_ahmed_7514e4af94630</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/rawia_ahmed_7514e4af94630"/>
    <language>en</language>
    <item>
      <title>Building an Accessible Angular Combobox: What the ARIA Checklist Doesn't Tell You</title>
      <dc:creator>Rawia Ahmed</dc:creator>
      <pubDate>Sat, 26 Sep 2026 02:20:20 +0000</pubDate>
      <link>https://dev.to/rawia_ahmed_7514e4af94630/building-an-accessible-angular-combobox-what-the-aria-checklist-doesnt-tell-you-5dhc</link>
      <guid>https://dev.to/rawia_ahmed_7514e4af94630/building-an-accessible-angular-combobox-what-the-aria-checklist-doesnt-tell-you-5dhc</guid>
      <description>&lt;p&gt;&lt;strong&gt;Your combobox passes the audit. It still doesn't work with a screen reader.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;I picked a combobox. It looks like the simplest component there is: an input, a list, pick one. It isn't.&lt;/p&gt;

&lt;p&gt;Most component libraries treat accessibility as a checklist. Add &lt;code&gt;role="listbox"&lt;/code&gt;, add &lt;code&gt;aria-expanded&lt;/code&gt;, 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.&lt;/p&gt;

&lt;p&gt;This post goes through the decisions I made building &lt;a href="https://www.npmjs.com/package/ngx-generic-combobox" rel="noopener noreferrer"&gt;ngx-generic-combobox&lt;/a&gt;, an Angular 22 component built on Signals. Most of them apply to any framework.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9kfy2gg9odltzib925pn.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9kfy2gg9odltzib925pn.gif" alt="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 " width="800" height="439"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Arrow keys, Escape, filtering and Enter. The panel on the right shows what a screen reader hears.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Focus never leaves the input
&lt;/h2&gt;

&lt;p&gt;This is the one most implementations get wrong.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The fix is virtual focus. DOM focus stays in the input, and &lt;code&gt;aria-activedescendant&lt;/code&gt; tells assistive technology which option is active:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt;
  &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"text"&lt;/span&gt;
  &lt;span class="na"&gt;role=&lt;/span&gt;&lt;span class="s"&gt;"combobox"&lt;/span&gt;
  &lt;span class="na"&gt;[attr.aria-expanded]=&lt;/span&gt;&lt;span class="s"&gt;"isOpen()"&lt;/span&gt;
  &lt;span class="na"&gt;[attr.aria-controls]=&lt;/span&gt;&lt;span class="s"&gt;"listboxId"&lt;/span&gt;
  &lt;span class="na"&gt;[attr.aria-activedescendant]=&lt;/span&gt;&lt;span class="s"&gt;"activeDescendantId()"&lt;/span&gt;
  &lt;span class="na"&gt;aria-autocomplete=&lt;/span&gt;&lt;span class="s"&gt;"list"&lt;/span&gt;
&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;There's a detail here that's easy to miss. When nothing is active, &lt;code&gt;aria-activedescendant&lt;/code&gt; should be &lt;strong&gt;absent&lt;/strong&gt;, not an empty string:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;activeDescendantId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;computed&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;activeIndex&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isOpen&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;optionId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Returning &lt;code&gt;null&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;effect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;index&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;activeIndex&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isOpen&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;index&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;option&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listbox&lt;/span&gt;&lt;span class="p"&gt;()?.&lt;/span&gt;&lt;span class="nx"&gt;nativeElement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;HTMLElement&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;option&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;scrollIntoView&lt;/span&gt;&lt;span class="p"&gt;?.({&lt;/span&gt; &lt;span class="na"&gt;block&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;nearest&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;block: 'nearest'&lt;/code&gt; scrolls the minimum needed, and does nothing when the option is already visible. Without it, the list jumps on every keypress.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Announce the count, not the list
&lt;/h2&gt;

&lt;p&gt;When you type into a combobox, the list filters. A sighted user sees that happen. A screen reader user gets silence.&lt;/p&gt;

&lt;p&gt;So there's a separate live region that announces how many results are left:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"ngx-combobox__sr-only"&lt;/span&gt; &lt;span class="na"&gt;role=&lt;/span&gt;&lt;span class="s"&gt;"status"&lt;/span&gt; &lt;span class="na"&gt;aria-live=&lt;/span&gt;&lt;span class="s"&gt;"polite"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;{{ announcement() }}&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;announcement&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;computed&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isOpen&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filtered&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;emptyMessage&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1 result available&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; results available`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's &lt;code&gt;polite&lt;/code&gt; on purpose. Reading the whole list out would interrupt the user mid-word. The count is enough to know whether to keep typing.&lt;/p&gt;

&lt;p&gt;And the "No results" row in the list is deliberately &lt;strong&gt;not&lt;/strong&gt; &lt;code&gt;role="option"&lt;/code&gt;. You can't select it, so it shouldn't be counted as an option.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Small keyboard decisions that matter
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Escape does two different things.&lt;/strong&gt; 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.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Escape&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
  &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preventDefault&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isOpen&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Navigation stops at the ends.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Disabled options stay in the DOM.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Home and End belong to the text cursor&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Blur restores the selected label.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tab never gets trapped.&lt;/strong&gt; It closes the list and moves on, and doesn't quietly select anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Visual accessibility too
&lt;/h2&gt;

&lt;p&gt;Screen readers aren't the whole story.&lt;/p&gt;

&lt;p&gt;The active option has a background colour &lt;strong&gt;and&lt;/strong&gt; an inset border, because colour alone fails &lt;a href="https://www.w3.org/WAI/WCAG22/Understanding/use-of-color.html" rel="noopener noreferrer"&gt;WCAG 1.4.1&lt;/a&gt;. Windows High Contrast mode drops backgrounds entirely, so there's a &lt;code&gt;forced-colors&lt;/code&gt; block that swaps in a system-coloured border:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight scss"&gt;&lt;code&gt;&lt;span class="k"&gt;@media&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;forced-colors&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;active&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nc"&gt;.ngx-combobox__option--active&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;border&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2px&lt;/span&gt; &lt;span class="nb"&gt;solid&lt;/span&gt; &lt;span class="n"&gt;Highlight&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And &lt;code&gt;prefers-reduced-motion&lt;/code&gt; turns off the chevron animation.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Test the contract, not the implementation
&lt;/h2&gt;

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

&lt;p&gt;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 "&lt;code&gt;aria-activedescendant&lt;/code&gt; points at the active option" won't.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What isn't done yet
&lt;/h2&gt;

&lt;p&gt;I'd rather be honest about this than oversell it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It hasn't been tested with real screen readers yet.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;No automated axe check in CI yet.&lt;/li&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install &lt;/span&gt;ngx-generic-combobox
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The source is on &lt;a href="https://github.com/RawiaAhmed/ngx-generic-combobox" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;, and every decision in this post has a comment in the code explaining why.&lt;/p&gt;

&lt;p&gt;If you use a screen reader and try it, I'd really like to hear what breaks.&lt;/p&gt;

</description>
      <category>angular</category>
      <category>a11y</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
  </channel>
</rss>
