Primitives
Combobox
An editable input paired with a filterable listbox popup, supporting single or multi selection with chips.
Type to filter, move the highlight with the arrow keys and commit with Enter. The highlighted option carries data-highlighted, and the filtering itself stays yours.
New to overlays in forty-cdk? Your first overlay walks a Popover from empty markup to styled-and-animated and explains the
@if/ open-state model and the portal → global CSS rule.
Headless: role="combobox" on the input, role="listbox" on the surface, role="option" on items, plus aria-activedescendant so DOM focus stays in the input. Implements the FormValueControl<readonly T[]> interface from @angular/forms/signals.
Supports both single (default) and multi-select. Multi mode renders the selected values as chips next to the input.
Two anatomies share the same core:
- Editable (default): the input is the visible field, the floating anchor, and the keyboard owner;
[forComboboxContent]is the listbox. This is the APG editable combobox. - Picker: a
[forComboboxTrigger]<button>keeps showing the committed selection while the search input lives inside the panel (a "combobox with trigger" picker). Add a[forComboboxList]so the popup can hold an input without violating ARIA owned-elements. See Picker anatomy.
[forCombobox] is generic over the option value type T (default string). Bind primitive ids for the simple case or full objects for richer models. The directive infers T from [(value)] and [forComboboxOption][value]. See Object values for the object-mode contract.
When to choose
- Combobox: an editable
<input>that filters arole="listbox"popup as the user types, witharia-activedescendantkeeping DOM focus in the field. - Select: the same popup with a non-editable trigger. Choose it when the value must come from the options and typing is only typeahead.
- Listbox: an in-page list of options, always visible, with no overlay and no field.
The picker anatomy blurs the line on purpose: a [forComboboxTrigger] button shows the committed value while the search input lives inside the panel. Reach for it when the collapsed control should read like a Select but the list still needs filtering.
Anatomy
The editable (default) anatomy is an <input> that filters a portaled listbox in place:
<div forCombobox #combobox="forCombobox" [(query)]="query" [(value)]="value">
<input forComboboxInput placeholder="Search…" />
<button forComboboxClear>×</button>
<!-- @if (combobox.open()) { -->
<div forComboboxContent>
<div forComboboxOption [value]="item.id" [label]="item.label">
<span forComboboxIndicator>✓</span>
{{ item.label }}
</div>
<div forComboboxEmpty>No matches.</div>
</div>
<!-- } -->
</div>
Editable + list (no trigger). Wrapping the options in a [forComboboxList] without adding a [forComboboxTrigger] is a supported shape, and the a11y-clean way to add non-option pieces ([forComboboxEmpty], [forComboboxStatus], [forComboboxAction]) to the editable anatomy. Because content carries role="listbox" (which may only own option / group children), moving the options into [forComboboxList] makes those pieces siblings of the listbox instead of invalid listbox children. Content becomes role-less and the list owns the listbox role. The role split keys off hasList, the focus model off trigger(), so focus still stays on the input the whole time:
<div forComboboxContent>
<div forComboboxList>
<div forComboboxOption [value]="item.id" [label]="item.label">{{ item.label }}</div>
</div>
<div forComboboxEmpty>No matches.</div>
</div>
A [forComboboxAction] requires this shape (see Action items).
Editable-anatomy caveat. For the common case of options plus only a
[forComboboxEmpty]/[forComboboxStatus]message (the bare anatomy above, no[forComboboxList]), the message sits directly inside[forComboboxContent]. This is still supported and does not throw, but it leaves therole="status"message as an owned child ofrole="listbox", a minoraria-required-ownedcompromise; wrap the options in a[forComboboxList](the "editable + list" shape) when you want the strictly-clean tree.
The picker anatomy adds a [forComboboxTrigger] <button> showing the committed selection, with the search input and a [forComboboxList] (role="listbox") nested inside the popup (see Picker anatomy). Multi mode wraps the chips + input in [forComboboxChips] (see Multi mode). Optional [forComboboxAnchor], [forComboboxStatus], [forComboboxGroup] / [forComboboxGroupLabel], and [forComboboxSeparator] pieces are covered in their own sections below.
Examples
Multi-select with chips
Pass multiple and render the committed values as chips inside [forComboboxChips]. Each chip has a remove button; Backspace from the empty input jumps to the last chip, and ← / → navigate between them.
Inline autocomplete
Type ger: the list narrows to Germany and the input completes to Germany, with the appended many selected so your next keystroke replaces it. Backspace removes the completion without bringing it back. The demo uses autocompleteMode='both'; Autocomplete modes compares it with the other three.
Action item (create on the fly)
A pinned [forComboboxAction] is a role=button affordance, not an option, so it never lands in value(), aria-setsize or aria-posinset. It emits (activate) on click / Enter / Space, and Tab reaches it in one keypress regardless of list length; Escape or an outside click still dismiss.
Selected: —. Type a name that isn't in the list and the Create action appears at the top — Tab reaches it without walking the options, and activating it never touches value() until your handler decides to.
Picker (trigger + in-panel search)
Click the [forComboboxTrigger] button, type into the search field inside the panel and pick a country: the button shows it and takes focus back. Reopen it and the search starts empty, with your pick checked. Picker anatomy covers the [forComboboxList] part this also needs, the focus hand-off and the anchor order.
Binding whole objects
forCombobox is generic over T. Bind the whole object to [forComboboxOption][value] and configure three hooks: [compareWith] to match by a stable key, [itemToStringLabel] for the visible label, and [itemToFormValue] to serialize what a native form submits. value() holds the full object.
Virtualized (1,000 options)
The primitive never owns the scroll container, so it virtualizes with any windowing strategy. The demo uses a dependency-free one. The consumer renders only the visible window and wires [totalCount], [visibleRange] and [forComboboxOption][posInSet]; (scrollToIndex) fires when navigation targets a row outside the window.
API
Input tables are not yet tabulated for this primitive. See the feature sections below for documented inputs and the prose descriptions of each input.
Data attributes
| Piece | Attribute | Values | |
|---|---|---|---|
[forCombobox] | data-state | open | closed | |
[forCombobox] | data-disabled | present / absent | |
[forCombobox] | data-readonly | present / absent | |
[forComboboxInput] | data-state | open | closed | |
[forComboboxInput] | data-disabled | present / absent | |
[forComboboxToggle] | data-state | open | closed | |
[forComboboxToggle] | data-disabled | present / absent | |
[forComboboxContent] | data-state | open | closed | |
[forComboboxOption] | data-state | checked | unchecked (membership in value(), both modes) | |
[forComboboxOption] | data-highlighted | present / absent (the current aria-activedescendant) | |
[forComboboxOption] | data-disabled | present / absent | |
[forComboboxAction] | data-highlighted | present / absent (the action currently holds DOM focus) | |
[forComboboxAction] | data-disabled | present / absent | |
[forComboboxIndicator] | data-state | checked | unchecked (mirrors the parent option) | |
[forComboboxChip] | data-value | the chip's serialized value (verbatim string, or itemToFormValue) | |
[forComboboxChip] | data-disabled | present / absent | |
[forComboboxSeparator] | data-orientation | horizontal | vertical |
Focus stays on the <input> the whole time the listbox is open, so options never get :focus. data-highlighted is the canonical hook for styling the keyboard-active option. A mouse press anywhere on the popup that lands on no focusable element (padding, a group label, an empty or status row) does not move focus either, so typing and the arrow keys keep working after it; the trade-off is that popup text cannot be selected with the mouse, as in a native combobox. A press on a focusable element inside the popup, such as a [forComboboxAction] or your own <button>, still focuses it.
Mount/visibility convention
[forComboboxContent] follows the floating-overlay convention: the consumer's signal drives @if, the directive emits dismiss events when it wants to be unmounted. No [hidden]. The visible input lives outside the overlay; only the listbox surface portals.
Anchoring to a field box
By default the listbox is positioned against [forComboboxInput]. When the input lives inside a decorated field box (padding, a prefix icon, a clear button, or the multi-mode chip cluster), anchoring to the bare <input> makes the panel narrower than the visible field and offset from its edge. Wrap the field box in [forComboboxAnchor] so floating-ui positions (and sizes, via --for-floating-anchor-width) the listbox against the box instead:
<div forCombobox #combobox="forCombobox" [(query)]="query" [(value)]="value">
<div forComboboxAnchor class="field-box">
<icon name="search" />
<input forComboboxInput placeholder="Search a fruit…" />
<button class="clear" (click)="combobox.clear()">×</button>
</div>
@if (combobox.open()) {
<div forComboboxContent style="width: var(--for-floating-anchor-width)">
@for (it of filtered; track it.id) {
<div forComboboxOption [value]="it.id" [label]="it.label">{{ it.label }}</div>
}
</div>
}
</div>
[forComboboxAnchor] changes only positioning. The input keeps aria-controls / aria-expanded / aria-activedescendant, all keyboard interaction, and its exemption from outside-pointer dismissal. Without an anchor the listbox falls back to the input, so existing markup is unaffected. It wins over a surrounding field's [forFieldAnchor]. Each [forCombobox] takes one [forComboboxAnchor], and a second one warns in dev mode. In multi mode, wrap [forComboboxChips] (which already wraps the chips + input) to anchor against the full chip cluster.
Toggle button
An editable combobox often carries a chevron button next to the input, as in the APG's editable combobox examples. Put [forComboboxToggle] on a real <button>:
<div forCombobox #combobox="forCombobox" [(query)]="query" [(value)]="value">
<div forComboboxAnchor class="field-box">
<input forComboboxInput placeholder="Search a fruit…" />
<button forComboboxToggle>▾</button>
</div>
@if (combobox.open()) {
<div forComboboxContent>
@for (it of filtered; track it.id) {
<div forComboboxOption [value]="it.id" [label]="it.label">{{ it.label }}</div>
}
</div>
}
</div>
A press closes an open listbox, or opens a closed one with the committed selection highlighted (the first enabled option when nothing is selected) and moves focus into the input. The press never takes focus itself, so an input that already has focus keeps it and the combobox is not marked touched. The button is exempt from the listbox's outside-pointer dismissal, so one press is one (openChange).
Unlike [forComboboxTrigger], a toggle keeps the editable anatomy: commitOnSelect still copies the picked label into the input, and query survives a close. The button is out of the Tab sequence (tabindex="-1") because the input already owns the keyboard, reflects aria-expanded and aria-controls (the listbox, while open), and carries native disabled from the combobox's effective disabled. Its accessible name defaults to 'Show options'; override it per instance with [ariaLabel], or for the scope with provideForComboboxDefaults({ toggleAriaLabel }).
Picker anatomy
The default (editable) anatomy is a text field that filters in place. A picker (a "combobox with trigger") is the other common shape: a button shows the committed selection (label + icon), and clicking it opens a panel whose search input filters a list. Reach for it when the closed control should read as "the selected thing", not as an editable field.
Add two parts:
[forComboboxTrigger]: a real<button>outside the@if (open()). It opens the panel, becomes the default positioning anchor, and is where focus returns on close.[forComboboxList]: therole="listbox"element nested inside[forComboboxContent], next to the input. It owns the options and the labelled role;[forComboboxContent]becomes a neutral popup surface.
<div forCombobox #combobox="forCombobox" [(value)]="value" [(query)]="query" [(open)]="open">
<button forComboboxTrigger>{{ selectedLabel() }}</button>
@if (combobox.open()) {
<div forComboboxContent>
<input forComboboxInput placeholder="Search…" />
<div forComboboxList>
@for (item of filtered(); track item.id) {
<div forComboboxOption [value]="item.id" [label]="item.label">{{ item.label }}</div>
}
<div forComboboxEmpty>No matches.</div>
</div>
</div>
}
</div>
Why the list part is required, not optional: a role="listbox" may only own option / group children (aria-required-owned-elements). Nesting the input inside a listbox would be invalid, so the listbox role moves to [forComboboxList] and the input sits beside it under the neutral popup surface. Put non-option chrome ([forComboboxInput], [forComboboxEmpty], [forComboboxStatus]) inside [forComboboxContent] but outside [forComboboxList].
Focus hand-off. Registering a trigger opts the combobox into the standard trigger-anchored focus model: on open, focus moves into the input (the search field inside the panel); on close, focus returns to the trigger. Both moves are vetoable via (autoFocusOnOpen) / (autoFocusOnClose) on [forCombobox], and the return is gated by [returnFocus] (default true). Escape stays owned by the input. See Focus & the (autoFocusOnOpen) / (autoFocusOnClose) hooks.
A trigger that registers late still owns the hand-off. The trigger does not have to be declared before [forComboboxContent], and does not have to exist when the panel first mounts. You can project it through <ng-content> from a wrapper, put it under a @defer, or gate it on an @if over loaded data. One caveat: focus moving into the panel is a mount-time event, so a trigger that arrives while the panel is already open does not retroactively pull focus out of wherever you left it. From that moment on it does own the return focus, (autoFocusOnClose) and the Escape fallback, and the next open moves focus into the input as usual.
Trigger keyboard. Click / Enter / Space toggle (open moves focus into the input). ArrowDown opens with the first enabled option highlighted; ArrowUp opens with the last.
Anchor preference. With a trigger present the panel anchors to it by default. An explicit [forComboboxAnchor] still wins (explicit anchor → trigger → input), so you can wrap a decorated trigger box and anchor against it.
Picking which anatomy. Use the editable anatomy for type-to-filter text fields and tag inputs (the input is always visible). Use the picker anatomy for select-like pickers where the closed state shows a chosen value and search is an in-panel affordance. Everything else works identically in both: filtering (the consumer's job), [(value)] / [(query)], object values, multi mode and virtualization.
Transient query. In the picker anatomy the in-panel [forComboboxInput] is a transient filter, not the value display: the committed selection lives on the [forComboboxTrigger]. So the combobox resets query to '' every time the panel closes, and single-mode activation never copies the option label into query (the editable anatomy's commitOnSelect is effectively off here). Reopen the panel and the search starts empty with the full option list, the previously-picked option carrying data-state="checked". commitOnSelect governs the editable anatomy only; to keep a typed filter across reopen in the picker, drive query yourself from (openChange).
Triggers stamped from outside-declared templates. Angular resolves ng-template DI at the template's declaration site, not where it is stamped. A [forComboboxTrigger] declared in a template outside the root throws the orphan error even when the template is rendered inside the root via ngTemplateOutlet. For that case the selector attribute accepts the root reference as a value, routerLink-style. Grab it with #root="forCombobox" and pass it through the outlet context. The bare valueless attribute keeps resolving via DI.
<div forCombobox #root="forCombobox" [(value)]="value" [(query)]="query">
<ng-container *ngTemplateOutlet="trig; context: { root }" />
@if (root.open()) {
<div forComboboxContent>…</div>
}
</div>
<ng-template #trig let-root="root">
<button [forComboboxTrigger]="root">{{ selectedLabel() }}</button>
</ng-template>
Action items
A combobox popup often needs an entry that is an action, not a value:
"Create new …", "Manage tags …", "Clear all". Semantically these are
role="button" actions, not role="option" selections, so [forComboboxAction]
renders one that stays out of the option/value collection entirely.
[forComboboxAction] requires a [forComboboxList]: content carries
role="listbox" in the editable anatomy, so a role="button" placed directly
inside it would be an invalid listbox child (aria-required-owned). Wrap the
options in a [forComboboxList] so the action becomes a sibling of the listbox.
An action rendered without a [forComboboxList] throws [forty-cdk/combobox] at
runtime. This is the "editable + list" shape (no [forComboboxTrigger] needed).
<div forCombobox #combobox="forCombobox" [(query)]="query" [(value)]="value">
<input forComboboxInput placeholder="Search…" />
@if (combobox.open()) {
<div forComboboxContent>
<button forComboboxAction (activate)="createNew(query())">Create "{{ query() }}"</button>
<div forComboboxList>
@for (it of filtered; track it.id) {
<div forComboboxOption [value]="it.id" [label]="it.label">{{ it.label }}</div>
}
</div>
</div>
}
</div>
An action:
- never touches
value/options(). It registers in a collection separate from options, sooptions(),aria-setsize, andaria-posinsetare unaffected and activation emits(activate)instead of mutating[(value)]. The consumer decides what happens and whether to close the popup afterwards. - is
role="button", notrole="option". Assistive tech announces it as an action, not as one of N choices. - is reached by Tab, not the arrow keys (see below), so it stays reachable no matter how long (or how virtualized) the option list is.
Use [forComboboxAction] for a pinned side-effect. For an entry that does
select (an "Add new" row that adds an item and commits it to value), use a
plain [forComboboxOption] (see the static options under Examples).
Focus & keyboard (model A)
While the popup is open, Tab / Shift+Tab cycle DOM focus around the ring
[input, …enabled actions] (in DOM order, wrapping both ways) without
dismissing the popup. Options stay arrow-navigated via aria-activedescendant;
actions stay Tab-focused. The two models never mix. This keeps a pinned action
reachable in a bounded number of keypresses regardless of the option count, which
a bottom-pinned option cannot guarantee under infinite scroll.
Because focus is trapped in the input↔actions ring while open, Escape (or an
outside pointer) is how you leave: Escape from an action closes the popup and
returns focus to the input (editable anatomy) or the [forComboboxTrigger]
(picker anatomy). Activation is click / Enter / Space and routes to (activate)
only. With no action registered, Tab keeps its default "close and let Tab flow on"
behaviour, so existing comboboxes are unchanged.
Actions live inside [forComboboxContent] and beside [forComboboxList] (never
inside it, in either anatomy), so they are naturally "inside" the outside-pointer /
outside-focus dismissal checks, exactly like the input.
Action item API
| Member | Type | Notes |
|---|---|---|
[disabled] | boolean | Drops the action out of the focus ring (tabindex removed), reflects aria-disabled, ignores activation. |
(activate) | output | Fired on click / Enter / Space. Never mutates [(value)]. |
[forComboboxAction] host-binds role="button", type="button" (on a native
<button> host only, leaving any other element with no type), a primitive-managed
tabindex, aria-disabled (when disabled), and reflects data-highlighted
while it holds DOM focus + data-disabled when disabled.
Out of scope (v1): grouped action clusters / multiple action zones, submenu-style nested actions, and actions that mutate
value(use a plain[forComboboxOption]).
Self-hiding pieces
[forComboboxClear] (nothing to clear) and [forComboboxEmpty] (options exist) hide themselves with an inline display: none in addition to the hidden attribute that removes them from the accessibility tree. Because the inline style beats any author selector rule, you can give these pieces a custom display (e.g. display: inline-flex for an icon) without a .x[hidden] { display: none } workaround. The directive's display: none still wins while the piece is hidden, and your display applies once it shows.
Two models, separately tracked
The combobox separates what the user is typing from what's been committed:
[(query)]: stringis the visible text the user is editing.[(value)]: readonly string[]is the committed selection. Single mode keeps 0 or 1 element; multi mode keeps any number.selectedItem: Signal<T | null>is a read-only single-select convenience view ofvalue: the sole selected item, ornullwhen none / many are selected. Lets single-select consumers skipvalue()[0]. (Distinct fromselected, which pairs every value with its resolved label for chip rendering.)
They diverge while the user types and resync on activation:
- Click / Enter on an option:
- Single mode →
valuebecomes[option.value]. IfcommitOnSelectis on (default),queryis overwritten with the option's label. Listbox closes. - Multi mode → option's value is toggled in/out of
value. IfcommitOnSelectis on (default),queryis cleared so the user can search the next item. Listbox stays open.
- Single mode →
- Clear button → both reset.
clearOnQueryChange(off by default, single mode only): flip on to dropvalueautomatically whenever the query is edited (useful when the user editing means "I'm picking a new one").restoreQueryOnClose(off by default, single mode only): flip on to put the selected label back into the input when the listbox closes without a pick. SeerestoreQueryOnClose.
commitOnSelect: single vs multi
The same boolean drives two different behaviours because what counts as "committing the selection" differs by mode. In single mode the query is the displayed label of the picked item, so committing means copying the label. In multi mode the query is the next-item search field, so committing means clearing it to prepare for the next pick. Concrete state walks (starting from query="", value=[]):
Single, commitOnSelect=true (default)
user types "ap" → query="ap" value=[]
user activates "Apple" → query="Apple" value=["apple"] ← label copied, listbox closes
Single, commitOnSelect=false
user types "ap" → query="ap" value=[]
user activates "Apple" → query="ap" value=["apple"] ← query untouched, listbox closes
Multi, commitOnSelect=true (default)
user types "ap" → query="ap" value=[]
user activates "Apple" → query="" value=["apple"] ← query cleared, listbox stays open
user types "ba" → query="ba" value=["apple"]
user activates "Banana"→ query="" value=["apple","banana"]
Multi, commitOnSelect=false
user types "ap" → query="ap" value=[]
user activates "Apple" → query="ap" value=["apple"] ← query untouched, listbox stays open
Disable commitOnSelect when your filter logic compares against query directly and the listbox should keep showing the just-narrowed set after activation, instead of resetting to "everything matches the picked label".
restoreQueryOnClose
In the editable anatomy, closing the listbox leaves query as the user left it, so typing "ap" over a committed "Banana" and pressing Escape keeps showing "ap". With [restoreQueryOnClose]="true", a single-select combobox restores the selected option's label on every close that is not a pick (Escape, an outside press, Tab, a [forComboboxToggle] press, closeOverlay()), and clears the input when nothing is selected. The input keeps focus on Escape, and the restored text reaches it even while focused. A pick still follows commitOnSelect.
Single, restoreQueryOnClose=true
user activates "Banana" → query="Banana" value=["banana"]
user types "ap" → query="ap" value=["banana"]
user presses Escape → query="Banana" value=["banana"] ← label restored, listbox closes
The label is the one selected() resolves: the option's own label once it has rendered, [itemToStringLabel] before that (a value bound before the listbox ever opened). Multi mode and the picker anatomy ignore the input. Enable it for the whole scope with provideForComboboxDefaults({ restoreQueryOnClose: true }).
Multi mode
Pass multiple and let the consumer render chips inside [forComboboxChips]. The primitive's selected() computed returns { value, label } pairs ready for @for:
<div forCombobox multiple [(value)]="tags" [(query)]="query" [(open)]="open">
<div forComboboxChips>
@for (chip of selected(); track chip.value) {
<span forComboboxChip [value]="chip.value">
{{ chip.label }}
<button forComboboxChipRemove>×</button>
</span>
}
<input forComboboxInput placeholder="Add tags…" />
</div>
@if (open()) {
<div forComboboxContent>
@for (it of filtered(); track it.id) {
<div forComboboxOption [value]="it.id" [label]="it.label">{{ it.label }}</div>
}
</div>
}
</div>
In multi mode, options with aria-selected="true" keep appearing in the listbox by default. aria-selected lets screen readers announce them as already-picked. To hide already-selected entries, the consumer filters them out of the rendered set themselves.
Chip keyboard
Chips are intentionally out of the Tab cycle: Tab from outside lands on the input, not on a chip. The user reaches chips via the input's Backspace heuristic; once focused, ArrowLeft/Right + Backspace/Delete drive everything (the ArrowLeft / ArrowRight roles swap under dir="rtl" so they always follow the visual order):
| Key on chip | Action (LTR) | |
|---|---|---|
| ArrowLeft | Focus previous chip; bounces if first. | |
| ArrowRight | Focus next chip; if last, hop to the input. | |
| Backspace / Delete | Remove this chip + focus the previous chip (or the input if it was the only / last chip). | |
| Escape | Return focus to the input. |
In RTL the chip cluster lays out right-to-left, so ArrowRight moves to the visually-next chip (DOM-previous) and ArrowLeft moves to the visually-previous one (DOM-next, hopping to the input at the leftmost visual edge).
[forComboboxChipRemove] is a click-only target (also out of Tab cycle) with auto-generated aria-label="Remove <chip label>". The name is computed per chip, so the piece takes no [ariaLabel] input and ignores a static aria-label attribute. Localize it centrally by overriding the scope's builder:
@Component({
providers: [provideForComboboxDefaults({ chipRemoveLabel: (label) => `Quitar ${label}` })],
})
Multi-mode Backspace heuristic
When the input is empty (no query) and the user presses Backspace, focus jumps to the last chip. A second Backspace there removes it. While typing (input non-empty), Backspace falls through to the native delete-char.
Open / close behavior
| Behavior | Default | Override | |
|---|---|---|---|
| Open on focus | false | [openOnFocus]="true" | |
| Open on query | true | [openOnQuery]="false" | |
| Auto-highlight first option | true | [autoHighlight]="false" to require arrowing to an option first | |
| Commit label / clear query on select | true | [commitOnSelect]="false" | |
| Clear value on query edit (single only) | false | [clearOnQueryChange]="true" | |
| Highlight the selection on open | first | openHighlight="selected" | |
| Restore label on close (single only) | false | [restoreQueryOnClose]="true" |
openHighlight decides where the editable anatomy's highlight lands when the listbox opens from focus, click, ArrowDown / ArrowUp or openOverlay() without an argument. With 'selected', a single-select combobox showing "Spain" reopens on "Spain" rather than on the first option; with nothing selected, ArrowDown still lands on the first option and ArrowUp on the last. Opening from a typed query always highlights the first match, because the list is a filter result there, and the picker anatomy always opens on the selection. Both inputs take their default from the scope: provideForComboboxDefaults({ openHighlight: 'selected', restoreQueryOnClose: true }).
Autocomplete modes
The autocompleteMode input mirrors the WAI-ARIA aria-autocomplete property:
'none': input is a free-text query; no completion.'list'(default): listbox shows filtered options; input shows verbatim what the user typed.'inline': the rest of the first matching label is auto-completed into the input as selected text; no listbox popup.'both': listbox opens and the input is auto-completed, combining'list'and'inline'.
Inline completion preserves the user's typed prefix as unselected and selects the appended remainder, so the next keystroke replaces the selection (matching native browser autofill behavior). Backspace deletes the selection without re-completing, so the user can always shorten the query.
The completed text is a suggestion: query() keeps the typed prefix until the user accepts it, and accepting writes the full label into query (emitting (queryChange)). These gestures accept a pending completion:
- Tab, before the listbox closes and focus moves on.
- Enter when no option is activated (the listbox is closed, or open with nothing highlighted).
- A caret move that keeps the completed text: ArrowLeft / ArrowRight, and Home / End while the listbox is closed. While it is open, Home and End move the highlight instead and the completion stays pending.
- A click in the input, and blur.
Typing replaces the suggestion, Backspace removes it, and Escape on an open listbox closes it and restores the typed prefix. Accepting never opens the listbox, so a completion accepted on blur does not reopen a popup the same click dismissed.
Pure
'inline'needs a warm cache.'inline'never opens the popup (per APG,aria-autocomplete="inline"has no listbox), so in the default@if (open())anatomy no[forComboboxOption]ever renders and the label cache starts cold. A first keystroke into a combobox that has never been opened completes against nothing; inline completion only works once the options have rendered at least once (the user opened the popup via ArrowDown or[openOnFocus], warming the cache). If completion must work from the very first keystroke, use'both', which opens the popup, so the options render and the cache warms. Leaving[forComboboxContent]permanently mounted instead is not a supported shape and warns in dev mode: mount is the open state for this surface, so it never runsanimate.enter/animate.leave, and its dismissible layer stays active while closed.
Dismiss events
Each dismiss reason emits a vetoable event from [forCombobox]. Call preventDefault() on the event to keep the listbox open.
| Output | When | |
|---|---|---|
(escapeKeyDown) | Escape pressed while the listbox is open and the input has focus. | |
(pointerDownOutside) | Pointer-down outside both input and content. | |
(focusOutside) | Focus moves outside both input and content. | |
(interactOutside) | Either of the two above. |
Focus & the autoFocusOnOpen / autoFocusOnClose hooks
The two anatomies have different focus models:
- Editable anatomy: the input retains focus the entire time the listbox is open and on close; the active option is tracked via
aria-activedescendant, never via.focus(). There is no automatic focus move, so(autoFocusOnOpen)/(autoFocusOnClose)never fire. If you need to move focus elsewhere, do it from your own keydown handler. The combobox won't fight you. - Picker anatomy: focus moves into the input on open and returns to the
[forComboboxTrigger]on close, exactly like the other trigger-anchored overlays ([forPopover],[forDropdownMenu],[forSelect]). Both moves emit a vetoable event on[forCombobox]; callpreventDefault()to keep focus where it is:
| Output | When | preventDefault() effect | |
|---|---|---|---|
(autoFocusOnOpen) | Just before focus enters the input on open. | Focus stays on the trigger. | |
(autoFocusOnClose) | Just before focus returns to the trigger. | Focus stays where it is. |
Return focus is also gated by [returnFocus] (default true) and is skipped on a Tab close (Tab already advanced focus past the closing panel).
Object values
Real apps usually have richer option models ({ id, label, ... }) where the user-facing label and the comparison key differ, plus extra fields the consumer wants on selection. [forCombobox] is generic over T to support that without forcing the consumer to stringify and re-hydrate.
Three inputs configure the object behaviour. Defaults make string mode work unchanged:
| Input | Default | Purpose | |
|---|---|---|---|
[compareWith] | (a, b) => a === b | How two items compare. Override for object values so selection / removal locate by id (or any stable key). | |
[itemToStringLabel] | (item) => String(item) | Render an item as a string. Drives commitOnSelect writes into the input and the chip-label fallback. | |
[itemToFormValue] | (item) => typeof item === 'string' ? item : JSON.stringify(item) | Serialize an item for the hidden input. Override to emit a per-item id (or any wire format your backend wants). |
@let q = query().toLowerCase(); @let filtered = cities().filter((c) =>
c.name.toLowerCase().includes(q));
<div
forCombobox
[(query)]="query"
[(value)]="value"
[(open)]="open"
[compareWith]="byId"
[itemToStringLabel]="toName"
name="city"
[itemToFormValue]="toId"
>
<input forComboboxInput placeholder="Search a city…" />
@if (open()) {
<div forComboboxContent>
@for (c of filtered; track c.id) {
<div forComboboxOption [value]="c">{{ c.name }}</div>
}
</div>
}
</div>
interface City {
id: string;
name: string;
}
readonly query = signal('');
readonly value = signal<readonly City[]>([]);
readonly open = signal(false);
readonly cities = signal<readonly City[]>([
{ id: 'paris', name: 'Paris' },
{ id: 'berlin', name: 'Berlin' },
]);
readonly byId = (a: City, b: City) => a.id === b.id;
readonly toName = (c: City) => c.name;
readonly toId = (c: City) => c.id;
The same three inputs cover multi mode + chips: bind <span forComboboxChip [value]="chip.value"> to the object and the chip's resolved label() falls back through itemToStringLabel when the option cache is cold (e.g. chips rendered before the listbox has opened).
Virtualization
For very large option sets (1k+) the consumer can render only the visible window and let the directive coordinate navigation across the full source. Three additive inputs / one output cover the wiring; non-virtualized usage is unchanged.
| Input / Output | Purpose | |
|---|---|---|
[totalCount]: number | undefined | Length of the filtered source array. Drives aria-setsize and lets navigation walk past the rendered window. Leave undefined (default) for non-virtualized lists. | |
[visibleRange]: [start, end) | undefined | Inclusive-exclusive index range currently rendered in the DOM. Pulled from your virtualizer's getVirtualItems(). | |
[forComboboxOption][posInSet] | Absolute index of this option in the source array. Required when virtualizing; the directive folds option data into a snapshot keyed by this index so it survives unmount. | |
(scrollToIndex) | Emitted when arrow keys (or Home / End) need to land on an option whose absolute index falls outside visibleRange(). Wire to the virtualizer's scrollToIndex(idx). |
How navigation flows when virtualizing:
- The user presses End while the visible window is
[0, 20)and the source has 1000 items. - The directive computes the next index (999) against
totalCount. It's outsidevisibleRange, so the directive emits(scrollToIndex)=999and remembers 999 as the pending pos. - Your virtualizer scrolls; the directive's
@formounts the option for index 999. - As soon as that option registers (at the matching
posInSet), the directive seedsaria-activedescendantto its id.
Inline autocomplete matches against the most recently rendered window overlaid with the position snapshot, so completion against off-screen labels still works. selected().label reads a separate, selection-keyed cache instead. That cache is bounded by the selection, so the label of a selected option survives close / re-open and any number of query rebuilds, but it is not resolved from the position snapshot: a value that enters the selection while its option is outside the rendered window (a [(value)] write restoring a saved selection, say) falls back to [itemToStringLabel] until that option renders once. Supply [itemToStringLabel] whenever the selection can be seeded from outside the list.
<div
forCombobox
[(query)]="query"
[(value)]="value"
[(open)]="open"
[totalCount]="filtered().length"
[visibleRange]="v.range()"
(scrollToIndex)="v.scrollToIndex($event, { align: 'auto' })"
>
<input forComboboxInput placeholder="Search 100k items…" />
@if (open()) {
<div forComboboxContent #scroll style="overflow: auto; max-height: 320px">
<div [style.height.px]="v.totalSize()" style="position: relative">
@for (vi of v.virtualItems(); track vi.key) {
<div
forComboboxOption
[value]="filtered()[vi.index]!.id"
[label]="filtered()[vi.index]!.label"
[posInSet]="vi.index"
[style.transform]="'translateY(' + vi.start + 'px)'"
style="position: absolute; left: 0; right: 0"
>
{{ filtered()[vi.index]!.label }}
</div>
}
</div>
</div>
}
</div>
readonly query = signal('');
readonly value = signal<readonly string[]>([]);
readonly open = signal(false);
// `filtered()` is the consumer's own filtered source array (see "Filtering is the consumer's job").
readonly scrollRef = viewChild<ElementRef<HTMLElement>>('scroll');
readonly scrollElement = computed(() => this.scrollRef()?.nativeElement ?? null);
readonly v = injectVirtualizer({
count: computed(() => this.filtered().length),
estimateSize: () => 36,
scrollElement: this.scrollElement,
});
This uses the library's own injectVirtualizer core: v.virtualItems() is the windowed slice, v.totalSize() the spacer height, v.range() feeds [visibleRange], and v.scrollToIndex(idx) brings an absolute index into view. The scroll container belongs to the consumer's virtualizer, not the directive (here [forComboboxContent] is the scroll element).
[totalCount] is also the result count the message pieces read: [forComboboxStatus]'s count() reports it rather than the size of the rendered window, so {{ status.count() }} results. announces the full count once and stays put while the window scrolls, and [forComboboxEmpty] stays hidden while a window with results has not rendered yet.
When [totalCount] is omitted, the directive falls back to options().length and behaves exactly as before: aria-setsize is left to the platform default and navigation never emits (scrollToIndex).
Disabled options off-screen. The directive learns an option's
disabledonly when it's been rendered at least once. While the consumer can pre-mark disabled rows with their own filter (most apps do), arrow nav cannot skip an off-screen disabled option it has never seen: it will land on it, the option will mount, and the next arrow press skips. Mark disabled rows in the source array if this matters.
Listbox virtualization ships the same contract:
[forListbox]defaults to roving tabindex (DOM focus on the actual option element) and switches to thearia-activedescendantmodel when you set[totalCount]. See the Listbox README "Virtualization" section.
Writing direction
[forCombobox] exposes a dir: 'ltr' | 'rtl' input (default 'ltr'). It drives:
- Chip keyboard navigation: ArrowLeft / ArrowRight roles swap so they follow the visual order of the chip cluster, not the DOM order. See Chip keyboard above.
- Default popover placement:
aligndefaults to'start'andsideto'bottom'.startandendresolve against the writing direction, so the default anchors the listbox to the visually-leading edge of the input in both LTR and RTL, and a consumer-provided[align]follows the direction the same way.provideForComboboxDefaults({ align, side })sets either for a whole scope.
The native <input> handles caret movement and BiDi from the document's CSS direction already, so there's nothing extra to do for the typed text itself.
Filtering is the consumer's job
The primitive is headless: it does not filter the registered options. The consumer reads [forCombobox][(query)], applies whatever match logic they want, and renders the filtered subset with @for. Each rendered [forComboboxOption] registers itself; the listbox tracks the live set automatically.
@let q = query().toLowerCase(); @let filtered = items.filter(it =>
it.label.toLowerCase().includes(q));
<div forCombobox #combobox="forCombobox" [(query)]="query" [(value)]="value">
<input forComboboxInput placeholder="Search a fruit…" />
@if (combobox.open()) {
<div forComboboxContent>
@for (it of filtered; track it.id) {
<div forComboboxOption [value]="it.id" [label]="it.label">{{ it.label }}</div>
}
<div forComboboxEmpty>No matches.</div>
</div>
}
</div>
[(query)] (the typed text) and [(value)] (the committed selection / form state) are the consumer's. Open state is separate: [forCombobox] owns it as a model<boolean>, and since the directive is exportAs: 'forCombobox' you can read it straight off a template reference variable (#combobox="forCombobox") and gate [forComboboxContent] on combobox.open(). Focus / query / arrow keys flip it; Escape, Tab, and outside-pointer flip it back. You need no separate open signal and no [(open)]. Bind [(open)]="mySignal" (as the multi / object / virtualization examples do) only when the component class needs to read or drive open state itself.
Static options alongside the @for
A sentinel option (an "Add new…" action, a "No results" row, a pinned default) can be rendered statically above or below the @for list. It does not need to be folded into the filtered collection:
<div forComboboxContent>
<div forComboboxOption [value]="addSentinel" [label]="'Add new…'">Add new…</div>
@for (it of filtered; track it.id) {
<div forComboboxOption [value]="it.id" [label]="it.label">{{ it.label }}</div>
}
</div>
Static and @for-rendered options share the same registry, navigation order (DOM order), filtering, and label cache. This is right when the entry selects (adds to value and commits). For a pinned entry that is a pure side-effect and must not land in value ("Create new…", "Manage tags…"), reach for [forComboboxAction] instead.
Signal Forms
[forCombobox] implements FormValueControl<readonly T[]>. Pair with [formField] for auto-wiring with @angular/forms/signals:
<div forCombobox [formField]="form.country">
<input forComboboxInput />
…
</div>
For a legacy <form action="…"> flow, set [name], and the directive mirrors [(value)] into N <input type="hidden"> siblings (one per array entry; zero when empty). String values land verbatim in the hidden input; object values default to JSON.stringify (override via [itemToFormValue], see below).
A single-select field is modeled as the same readonly T[], kept at length ≤ 1, and bound with [formField] directly, so single mode needs no adapter. A FieldTree<T | null> cannot bind here; map to that shape at the edge that needs it. See the selection value-type contract.
Keyboard
Focus stays in the input throughout: arrow keys move the listbox's active descendant (the highlighted option), not DOM focus.
| Key | Action | |
|---|---|---|
| ArrowDown | Open listbox + move activedescendant to next enabled option (or first when none; the selection under openHighlight="selected"). | |
| ArrowUp | Open listbox + move activedescendant to previous enabled option (or last when none; the selection under openHighlight="selected"). | |
| Home (open) | Move activedescendant to first enabled option. | |
| End (open) | Move activedescendant to last enabled option. | |
| PageUp (open) | Move activedescendant to first enabled option. | |
| PageDown (open) | Move activedescendant to last enabled option. | |
| Enter (open) | Activate the activedescendant (single: replace + close; multi: toggle + stay open). | |
| Escape (open) | Close the listbox. Focus stays in the input. | |
| Tab (open, no action) | Close the listbox and let Tab flow to the next focusable. | |
| Tab / Shift+Tab (open, action present) | Move focus around the input↔actions ring without dismissing (see Action items). | |
| Backspace (empty input, multi only) | Focus the last chip; a second Backspace there removes it. | |
| ArrowLeft / ArrowRight | Move the caret, accepting a pending inline completion. | |
| Printable keys | Update query. With 'inline' / 'both' autocomplete, complete the rest of the first match into the input as selected text. |
With 'inline' / 'both' autocomplete, Tab, Enter with no option activated, and Home / End while closed also accept a pending completion (see Autocomplete modes).
Hovering an option also makes it the activedescendant, so mouse and keyboard intent stay synchronized. For a short window after the directive scrolls an option into view (arrow navigation, the auto-highlight seed after the list changes, and the reveal on open), a hover is ignored, so an option the scroll slides under a resting cursor cannot take the highlight.
Accessibility
Implements the WAI-ARIA Combobox pattern.
- Apply the input directive to an actual
<input>: the browser's caret and selection semantics are what make inline autocomplete work, and screen readers expect a real text field forrole="combobox". role="listbox"lives on[forComboboxContent]in the editable anatomy and on[forComboboxList]in the picker anatomy; the input'saria-controlstargets whichever carries it. In the picker anatomy the popup surface ([forComboboxContent]) is role-less so it can hold the input next to the list without anaria-required-owned-elementsviolation.aria-multiselectable="true"(multi mode) and the labelled role (aria-label/aria-labelledby, pointing at the input) sit on whichever element carriesrole="listbox": content in the editable anatomy, the list in the picker anatomy.[forComboboxTrigger](picker anatomy) is a real<button>reflectingaria-haspopup="listbox",aria-expanded,aria-controls(the popup surface, while open), and nativedisabledfrom the combobox's effective disabled. It is exempt from the popup's outside-pointer dismissal layer, like the input.[forComboboxContent]and[forComboboxList]cancel amousedownthat would otherwise move focus onto the surface or blur the input, so a press between options leaves focus in the input. A press on a focusable descendant is let through.[forComboboxToggle](editable anatomy) is a real<button>withtabindex="-1", a localizablearia-label,aria-expanded,aria-controls(the listbox, while open) and nativedisabled. It cancelsmousedownso focus stays in the input, and it is exempt from the outside-pointer dismissal layer.- In single mode,
aria-selected="true"follows the activedescendant (the option Enter would activate). In multi mode it follows membership invalue(), so every selected option carriesaria-selected="true"simultaneously. data-state="checked" | "unchecked"always reflects membership invalue(), so consumers can paint a checkmark icon with pure CSS regardless of mode.data-highlighted=""marks the option that is the currentaria-activedescendant. Because focus stays on the<input>, there is no:focuson the option to style.data-highlightedis the canonical CSS hook.- Disabled options keep the host
aria-disabled="true". Click and hover (activedescendant pinning) are no-ops on disabled options. [forComboboxSeparator]never registers with the listbox's option collection, so keyboard navigation skips it automatically. It carriesrole="separator"and emitsaria-orientationonly fororientation="vertical", becausehorizontalis the ARIA default;data-orientationis always stamped for styling. Setdecorativewhen the surrounding options already convey the split. Setting it switches the line torole="none"and dropsaria-orientation, matching the shared separator emission policy.[forComboboxGroup]is purely advisory grouping: options inside still register flatly with the root, so navigation flows through groups without interruption.[forComboboxEmpty]and[forComboboxStatus]carryrole="status"and nothing else. The role already impliesaria-live="polite"andaria-atomic="true", so the empty-state message is announced, whole, when filtering removes all matches. The role is the channel they keep (rather than the attribute pair) because[forComboboxEmpty]self-hides and comes back with its message already in the DOM: a live role is what screen readers read reliably on insertion.- Non-option pieces (
[forComboboxAction], and ideally[forComboboxEmpty]/[forComboboxStatus]) belong inside[forComboboxContent]but outside[forComboboxList], becauserole="listbox"may only ownoption/groupchildren (aria-required-owned-elements). Wrapping the options in a[forComboboxList](the "editable + list" shape) makes those pieces siblings of the listbox.[forComboboxAction]requires a[forComboboxList]and throws[forty-cdk/combobox]without one;[forComboboxEmpty]/[forComboboxStatus]stay lenient in the bare editable anatomy (documented compromise, see the editable-anatomy caveat). - The input element is exempt from the listbox's outside-pointer dismissal layer, so a click on the input while the listbox is open routes through
(click)(toggle / focus open) instead of double-firing as an outside dismissal.
Styling
forty-cdk ships no styles: put your own class on each piece and key your CSS off the data-* attributes listed under Data attributes, not off the for* selectors (Styling forty-cdk explains why).
CSS custom properties
[forComboboxContent] is portaled to document.body and gets its position resolved by floating-ui. The resolved geometry is exposed as custom properties on the content host (cleared on close):
| Custom property | Type / range | Meaning | |
|---|---|---|---|
--for-floating-anchor-width | px | Anchor (input / wrapper) width. Match the listbox to the input with width: var(--for-floating-anchor-width). | |
--for-floating-anchor-height | px | Anchor height. | |
--for-floating-available-width | px | Space available along the inline axis (floating-ui size middleware). Clamp with max-width. | |
--for-floating-available-height | px | Space available along the block axis. Clamp with max-height. | |
--for-floating-content-transform-origin | <origin> keywords | transform-origin matching the resolved side / align, so a scale enter animation pivots from the input. |
[forComboboxContent]is portaled todocument.body, so it lives outside your component's view-encapsulated styles. Style it with global CSS (or a class you pass through) and the shared positioner properties above. See Styling floating content for the full positioner-variable list and the portal styling rules.
.combobox-option[data-highlighted] {
background: var(--accent);
}
.combobox-option:not([data-disabled]) {
cursor: pointer;
}
Wrapping in a design system
Wrapping form primitives documents both supported wrapper patterns: hostDirectives with the exported FOR_COMBOBOX_HOST_DIRECTIVE_INPUTS / FOR_COMBOBOX_HOST_DIRECTIVE_OUTPUTS name tuples, and subclassing.