The exact problem: a custom combobox is fully keyboard-operable and completely unusable when it fails validation — the error is attached to a wrapper the screen reader never reads, and pressing Escape to dismiss the popup also dismisses the message.

Context and Prerequisites

The keyboard contract itself is in keyboard navigation patterns for forms. This page covers the part that is specific to validation: where the error attaches on a composite widget, and how the popup’s own key handling has to coexist with the form’s.

The Anatomy That Decides Everything

A combobox is three elements pretending to be one, and only one of them is the form control:

<!-- The wrapper is a layout element. It is NOT the control, and attaching
     validation state to it means nothing is announced. -->
<div class="combobox">
  <label id="country-label" for="country">Country</label>
  <!-- THIS is the control: it carries role, value, state and description. -->
  <input id="country" role="combobox" type="text"
         aria-expanded="false" aria-controls="country-list"
         aria-activedescendant=""
         aria-invalid="true" aria-describedby="country-error" aria-autocomplete="list">
  <ul id="country-list" role="listbox" aria-labelledby="country-label" hidden>
    <li id="country-opt-1" role="option" aria-selected="false">United Kingdom</li>
  </ul>
  <p id="country-error">Choose a country from the list</p>
</div>

aria-invalid and aria-describedby go on the element with role="combobox" — the input — because that is what receives focus and what a screen reader announces. On the wrapper they are announced by nothing.

Only one of the three elements is the form control The wrapper div owns layout and nothing else — no role, no state, and crucially no validation attributes, because nothing announces it. The input carries role combobox, the current value, the expanded state, the active descendant pointer, and both aria-invalid and aria-describedby; it is the element that receives focus and the element a screen reader describes. The listbox owns the options and its own labelling, and it is referenced by the input rather than describing it. Attaching validation to the wrapper is the commonest mistake and produces a message that is visible and never announced. the wrapper layout only no role no aria-invalid no aria-describedby nothing announces it, so nothing on it is heard the input — the control role="combobox" aria-expanded aria-activedescendant aria-invalid aria-describedby focus lands here; so does the query the listbox role="listbox" options with ids labelled by the same label never focused itself referenced BY the input, it does not describe it

Key Handling That Coexists with the Form

input.addEventListener('keydown', (e) => {
  switch (e.key) {
    case 'ArrowDown':
    case 'ArrowUp':
      // Owned by the widget: move the active option and preventDefault so the
      // page does not scroll underneath the popup.
      e.preventDefault();
      moveActive(e.key === 'ArrowDown' ? 1 : -1);
      break;
    case 'Enter':
      if (isOpen()) {
        // Commit the highlighted option. preventDefault so this Enter does NOT
        // also submit the form — the reader was choosing, not submitting.
        e.preventDefault();
        commitActive();
      }
      // When closed, Enter falls through and submits, which is correct.
      break;
    case 'Escape':
      if (isOpen()) {
        // Close and restore the typed value. Do NOT clear the validation
        // message: Escape dismisses the popup, not the form's judgement.
        e.preventDefault();
        close({ restoreTypedValue: true });
      }
      break;
    case 'Tab':
      // Never trap Tab in a combobox. Close, commit nothing, and let focus go.
      if (isOpen()) close({ restoreTypedValue: true });
      break;
  }
});

The Enter branch is the one that causes real damage when it is wrong. An open combobox that lets Enter reach the form submits it while the reader was choosing an option — and because the value had not been committed, it submits without their choice.

When each key is handled, and when it is let through Arrow keys are always handled by the widget and always prevent default, so the page does not scroll behind an open popup. Enter is handled only when the popup is open, where it commits; when closed it is let through and submits the form. Escape is handled only when open, where it closes and restores the typed value; when closed it is let through, so a surrounding dialog can close. Tab is never handled: the popup closes and focus leaves. Key Popup open Popup closed Arrow up/down move the active option open the popup Enter commit — never submit let through — submits Escape close, restore typed value let through — a dialog may close Tab close, then let through let through Two of these eight cells are the ones that break forms: Enter submitting while open, and Tab being trapped.

Failure Modes and Edge Cases

1. Validation fires while the popup is open

The reader is mid-selection and a message appears under the popup, moving the page. Validate on close or on blur, not on each keystroke of the query.

2. The message is hidden behind the popup

A message rendered directly below an input that also renders a popup below it will be covered. Reserve the space, or render the popup above when there is a message.

3. aria-activedescendant points at a removed option

Filtering the list removes options. Reset the active descendant whenever the list changes, or the reader hears nothing on the next arrow press.

4. The typed value is not the committed value

A reader who types “Unit” and tabs away without choosing has a query, not a selection. Decide explicitly: either treat it as empty and let the required rule fire, or auto-commit the single match — and never submit a query string as if it were a chosen id.

5. Escape clearing the error

Escape closes the popup. It has nothing to say about the form’s validation state, and clearing the message on it removes the only explanation of why the field is red.

Two things that must never be confused The query is what the reader has typed into the input; it is a filter, it changes on every keystroke, and it is not a value. The selection is the option they committed; it has an identifier, it changes only on commit, and it is what the form submits. Treating the query as the selection means submitting whatever was typed, which produces a server error about a value the reader never chose. Treating the selection as the query means the input cannot be typed in at all. the query what has been typed a filter, not a value changes on every keystroke never submitted the selection the committed option has an identifier changes only on commit this is what submits A combobox holds both at once, and the required rule is about the selection — never about whether the input looks filled.

Verification Checklist

Common Pitfalls

  • Putting the validation attributes on the wrapper. The wrapper has no role and is never announced, so the message is visible and completely silent. They belong on the element carrying role="combobox".
  • Letting Enter submit while the popup is open. The reader was choosing an option, and the form submits without their choice — because the value they were about to commit was never committed.
  • Clearing the error on Escape. Escape closes the popup. It has nothing to say about whether the field passed validation, and clearing the message removes the only explanation of why the field is marked.
  • Leaving aria-activedescendant pointing at a filtered-out option. The reader presses an arrow key and hears nothing, because the id no longer resolves. Reset it whenever the option list changes.
  • Submitting the query string as the value. A reader who typed “Unit” and tabbed away has a filter, not a selection. Submitting it produces a server error about a value they never chose.

Related

Keyboard Navigation Patterns for Forms

Frequently Asked Questions

Where exactly does aria-invalid go on a combobox?

On the element carrying role=“combobox”, which in the current pattern is the text input. That is what takes focus and what a screen reader describes, so it is the only place where the state is announced. The wrapper div is a layout element with no role, and attributes on it are read by nothing — which is why the bug presents as a message that is plainly visible and completely silent.

Should Enter submit the form when the popup is open?

No. The reader is choosing an option, and Enter is how they choose it, so preventDefault and commit. When the popup is closed, let Enter through to the form as it would from any other input. Getting this backwards produces the worst version of the bug: the form submits without the reader’s choice, because the value they were about to commit was never committed.

What should happen to a typed query that matches nothing?

Decide explicitly and document it. Treating it as empty is usually right for a combobox backed by a fixed list — the required rule then fires and the reader is told to choose from the list. Auto-committing a single remaining match is reasonable where the list is long and the reader has clearly narrowed it. What is never right is submitting the query string as if it were a chosen identifier, which produces a server error about a value the reader never selected.