Skip to main content
cngx-src documentation

CngxListbox

DirectivePrimaryv0.1.0WCAG AA

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 via cdkListbox directive)
  • mat-selection-list (multi select list from @angular/material/list)

Why better than Material

  1. Single source of truth for ARIA: every attribute is a computed().
  2. Selection shares the same CngxActiveDescendant used by menus and comboboxes - one mental model for all typeahead widgets.
  3. isAllSelected and selectedLabels are derived signals, not manual callbacks.
  4. Forms integration is decoupled: the listbox never imports @angular/forms.

https://cngxjs.github.io/cngx/examples/#/common/interactive/listbox/search/command-palette https://cngxjs.github.io/cngx/examples/#/common/interactive/listbox/trigger/select-dropdown https://cngxjs.github.io/cngx/examples/#/common/interactive/listbox/base/multi-select https://cngxjs.github.io/cngx/examples/#/common/interactive/listbox/base/single-select https://cngxjs.github.io/cngx/examples/#/forms/field/listbox-forms/material-mat-select-via-cngxbindfield https://cngxjs.github.io/cngx/examples/#/forms/field/listbox-forms/reactive-forms-adapted-via-adaptformcontrol https://cngxjs.github.io/cngx/examples/#/forms/field/listbox-forms/signal-forms-multi-select-min-2 https://cngxjs.github.io/cngx/examples/#/forms/field/listbox-forms/signal-forms-single-select

Metadata#

Host#

Dependencies#

CngxActiveDescendantinject()selfhostad

Underlying CngxActiveDescendant host directive. Exposed so triggers (e.g. CngxListboxTrigger) can drive navigation without ancestor injection.

Relationships

Index#

Inputs#

cngxSearchRef#CngxListboxSearch | null
input()

Optional 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.

default null
compareWith#(a: T, b: T) => boolean

Equality function for matching values. Defaults to Object.is.

default Object.is as (a: T, b: T) => boolean
input()

Optional 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).

externalActivation#boolean
input()

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.

default false
label#string
input()Required

Accessible label for the listbox region.

multiple#boolean
input()

Whether multiple options can be selected.

default false
selectedValues#T[]
model()

Two-way multi-value binding.

default []
value#T | undefined
model()

Two-way single-value binding.

Outputs#

selectedValues#T[]
model()

Two-way multi-value binding.

value#T | undefined
model()

Two-way single-value binding.

Methods#

clear#void

Clear all selections.

deselect#void
deselect(value: T)

Remove value from the selection. No-op if not selected.

@paramvalueT
getLabel#string | null
getLabel(value: T)

Returns the resolved label for a value, or null if no such option.

@paramvalueT
isSelected#boolean
isSelected(value: T)

Whether value is currently part of the selection.

@paramvalueT
select#void
select(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.

@paramvalueT
selectAll#void

Select every non-disabled option. Only valid in multi mode - in single mode this is a no-op.

toggle#void
toggle(value: T)

Flip selection state for value.

@paramvalueT

HostBindings#

BindingExpression
[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 };