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.
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.
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.
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
Entersubmit 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-activedescendantpointing 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 — the full key contract
- Wiring aria-describedby for Multiple Errors — attaching the message
- Roving tabindex for Radio and Checkbox Groups — the other composite widget
← 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.