CngxListbox
projects/common/interactive/listbox/listbox.directive.ts
Import#
import { CngxListbox } from '@cngx/common/interactive'
Description#
Composite listbox primitive built on top of CngxActiveDescendant.
Reads options from content children, exposes single- or multi-value
selection state via two-way [(value)] / [(selectedValues)] bindings, and
drives aria-selected / aria-multiselectable reactively.
value and selectedValues are model() signals - [value],
(valueChange), and [(value)] bindings all work identically. Forms
integration (<cngx-form-field>) is bolted on via the sibling
CngxListboxFieldBridge directive from @cngx/forms/field - this atom
stays Forms-agnostic.
Material / CDK equivalent
cdk-listbox(single/multi select viacdkListboxdirective)mat-selection-list(multi select list from@angular/material/list)
Why better than Material
- Single source of truth for ARIA: every attribute is a
computed(). - Selection shares the same
CngxActiveDescendantused by menus and comboboxes - one mental model for all typeahead widgets. isAllSelectedandselectedLabelsare derived signals, not manual callbacks.- Forms integration is decoupled: the listbox never imports
@angular/forms.
Metadata#
Host#
Dependencies#
CngxActiveDescendantinject()selfhostadUnderlying CngxActiveDescendant host directive. Exposed so triggers
(e.g. CngxListboxTrigger) can drive navigation without ancestor injection.
Relationships
Used by11
Index#
Outputs
Derived State
HostBindings
Inputs#
CngxListboxSearch | nullOptional explicit reference to a CngxListboxSearch whose term drives
filteredOptions. Consumers wire this up with a template reference:
[cngxSearchRef]="search" and #search="cngxListboxSearch". No ancestor
injection - orthogonal composition, like CngxSortHeader + CngxSortRef.
null(a: T, b: T) => booleanEquality function for matching values. Defaults to Object.is.
Object.is as (a: T, b: T) => booleanreadonly CngxOption[] | undefinedOptional explicit option list. When set, takes precedence over content
projection. Useful for composites that project options via <ng-content>
and query them one layer up (e.g. CngxSelect's declarative mode).
When true, the listbox stops auto-writing its own value / selectedValues
on AD-activation. The consumer (typically a Level-3 composite like
CngxSelect running an async [commitAction]) fully owns the value
mutation flow and can intercept activations BEFORE any write happens.
Why this exists.
The commit flow needs to snapshot the pre-pick value synchronously when
the user clicks an option - to roll back to it on error. Without this
flag, CngxListbox writes value via two-way binding BEFORE the consumer's
own ad.activated subscriber runs, and the pre-pick value is already
gone by the time we try to snapshot it. Flipping this flag lets the
consumer be the single writer.
Default false preserves the self-contained listbox behaviour used
everywhere outside the select family.
falseOutputs#
Methods#
string | nullgetLabel(value: T)Returns the resolved label for a value, or null if no such option.
TisSelected(value: T)Whether value is currently part of the selection.
Tselect(value: T)Select the given value. In single mode, replaces the current selection. In multi mode, adds to the selection if not already present. Values that do not correspond to any option are ignored.
TSelect every non-disabled option. Only valid in multi mode - in single mode this is a no-op.
HostBindings#
| Binding | Expression |
|---|---|
[attr.aria-label] | label() |
[attr.aria-multiselectable] | multiple() ? "true" : null |
Two synced listboxes
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { CngxListbox, CngxOption } from '@cngx/common/interactive';
/**
* Two listboxes, one signal. `CngxListbox.value` is a `model()`, so
* `[(value)]="pick"` two-way binds both boxes to the SAME `WritableSignal`.
* Pick in either and the other follows - they never talk to each other, both
* views simply derive from one source (Ableitung statt Verwaltung). No
* `cngx-form-field` and no field bridge are involved; syncing two listboxes is
* a `CngxListbox` capability, not a forms concern.
*/
@Component({
selector: 'app-root',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CngxListbox, CngxOption],
styles: `
.demo {
display: grid;
gap: 18px;
max-width: 520px;
padding: 16px;
font: 14px/1.4 system-ui, sans-serif;
}
.demo .lead {
margin: 0;
opacity: 0.7;
font-size: 13px;
}
.demo .lead code {
font-family: ui-monospace, monospace;
}
.demo .boxes {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
align-items: start;
}
.demo .cap {
display: block;
margin-bottom: 6px;
font-weight: 500;
}
.demo .listbox {
display: grid;
gap: 2px;
padding: 4px;
border: 1px solid #b0b0b0;
border-radius: 6px;
}
.demo .listbox:focus-visible {
outline: 2px solid #1a56c4;
outline-offset: 1px;
}
.demo .option {
padding: 6px 10px;
border-radius: 4px;
cursor: pointer;
}
.demo .option:hover {
background: #eef2ff;
}
.demo .option[aria-selected='true'] {
background: #1a56c4;
color: #fff;
}
.demo .shared {
display: grid;
grid-template-columns: max-content 1fr;
gap: 3px 14px;
padding: 10px 12px;
border-radius: 6px;
background: #f4f6fb;
font: 12px/1.5 ui-monospace, monospace;
}
.demo .shared dt {
margin: 0;
opacity: 0.6;
}
.demo .shared dd {
margin: 0;
font-weight: 600;
color: #1a56c4;
}
`,
template: `
<div class="demo">
<p class="lead">
Two listboxes bound to one signal via <code>[(value)]="pick"</code>. Pick in
either - the other follows. Both views derive from one source; nothing syncs them directly.
</p>
<div class="boxes">
<div>
<span class="cap">Listbox A</span>
<div cngxListbox [(value)]="pick" [label]="'Size A'" tabindex="0" class="listbox">
<div cngxOption value="s" class="option">Small</div>
<div cngxOption value="m" class="option">Medium</div>
<div cngxOption value="l" class="option">Large</div>
<div cngxOption value="xl" class="option">X-Large</div>
</div>
</div>
<div>
<span class="cap">Listbox B (synced)</span>
<div cngxListbox [(value)]="pick" [label]="'Size B'" tabindex="0" class="listbox">
<div cngxOption value="s" class="option">Small</div>
<div cngxOption value="m" class="option">Medium</div>
<div cngxOption value="l" class="option">Large</div>
<div cngxOption value="xl" class="option">X-Large</div>
</div>
</div>
</div>
<dl class="shared">
<dt>shared pick()</dt><dd>{{ pick() ?? '—' }}</dd>
</dl>
</div>
`,
})
export class SyncedListboxesExample {
// The single source both listboxes two-way bind to.
protected readonly pick = signal<string | undefined>(undefined);
}
export { SyncedListboxesExample as AppComponent };