bpdm/ui
Form

Multi-select

Select multiple options — chips or count display, searchable, accessible, in React (@bpdm/ui) and Angular (@bpdm/ng).

The Multi-select lets users pick several options from a data-driven list. Selected items show as chips (with a "+N" overflow) or as a count, with optional search and a "select all" row.

Usage

Selected options appear as removable chips.

multi-select-usage.tsx
import { MultiSelect } from '@bpdm/ui/multi-select';

const FRAMEWORKS = [
  { value: 'react', label: 'React' },
  { value: 'angular', label: 'Angular' },
  { value: 'vue', label: 'Vue' },
  { value: 'svelte', label: 'Svelte' },
];

export function MultiSelectUsage() {
  return <MultiSelect options={FRAMEWORKS} defaultValue={['react', 'vue']} placeholder="Select frameworks" />;
}
multi-select-usage.ts
import { Component } from '@angular/core';
import { BpdmMultiSelect } from '@bpdm/ng';

@Component({
  selector: 'multi-select-usage',
  imports: [BpdmMultiSelect],
  templateUrl: './multi-select-usage.html',
})
export class MultiSelectUsage {
  readonly frameworks = [
    { value: 'react', label: 'React' },
    { value: 'angular', label: 'Angular' },
    { value: 'vue', label: 'Vue' },
    { value: 'svelte', label: 'Svelte' },
  ];
}
multi-select-usage.html
<bpdm-multi-select [options]="frameworks" [defaultValue]="['react', 'vue']" placeholder="Select frameworks" />

Chip overflow

maxDisplay caps how many chips show; the rest collapse into a "+N". Here maxDisplay={2} with four selected shows two chips and "+2".

multi-select-overflow.tsx
import { MultiSelect } from '@bpdm/ui/multi-select';

const frameworks = [
  { value: 'react', label: 'React' },
  { value: 'vue', label: 'Vue' },
  { value: 'svelte', label: 'Svelte' },
  { value: 'solid', label: 'Solid' },
];

export function ChipOverflow() {
  return (
    <MultiSelect
      options={frameworks}
      maxDisplay={2}
      defaultValue={['react', 'vue', 'svelte', 'solid']}
      placeholder="Select frameworks"
    />
  );
}
multi-select-overflow.component.ts
import { Component } from '@angular/core';
import { BpdmMultiSelect } from '@bpdm/ng';

@Component({
  selector: 'app-chip-overflow',
  standalone: true,
  imports: [BpdmMultiSelect],
  template: `
    <bpdm-multi-select
      [options]="frameworks"
      [maxDisplay]="2"
      [defaultValue]="['react', 'vue', 'svelte', 'solid']"
      placeholder="Select frameworks" />
  `,
})
export class ChipOverflowComponent {
  readonly frameworks = [
    { value: 'react', label: 'React' },
    { value: 'vue', label: 'Vue' },
    { value: 'svelte', label: 'Svelte' },
    { value: 'solid', label: 'Solid' },
  ];
}

Count display

By default up to maxDisplay chips show before a "+N". Set maxDisplay={0} to show a compact "N selected" count instead.

multi-select-count.tsx
import { MultiSelect } from '@bpdm/ui/multi-select';

const frameworks = [
  { value: 'react', label: 'React' },
  { value: 'angular', label: 'Angular' },
  { value: 'vue', label: 'Vue' },
];

export function CountDisplay() {
  return <MultiSelect options={frameworks} maxDisplay={0} defaultValue={['react', 'angular', 'vue']} />;
}
multi-select-count.component.ts
import { Component } from '@angular/core';
import { BpdmMultiSelect } from '@bpdm/ng';

@Component({
  selector: 'app-count-display',
  standalone: true,
  imports: [BpdmMultiSelect],
  template: `<bpdm-multi-select [options]="frameworks" [maxDisplay]="0" [defaultValue]="['react', 'angular', 'vue']" />`,
})
export class CountDisplayComponent {
  readonly frameworks = [
    { value: 'react', label: 'React' },
    { value: 'angular', label: 'Angular' },
    { value: 'vue', label: 'Vue' },
  ];
}

Grouped & searchable

Pass groups ({ label, options }) for labelled sections, and searchable to filter.

multi-select-grouped.tsx
import { MultiSelect } from '@bpdm/ui/multi-select';

const CITIES = [
  {
    label: 'Europe',
    options: [
      { value: 'lon', label: 'London' },
      { value: 'par', label: 'Paris' },
      { value: 'ber', label: 'Berlin' },
    ],
  },
  {
    label: 'Americas',
    options: [
      { value: 'nyc', label: 'New York' },
      { value: 'sf', label: 'San Francisco' },
      { value: 'tor', label: 'Toronto' },
    ],
  },
];

export function GroupedCities() {
  return <MultiSelect options={CITIES} searchable placeholder="Select cities" />;
}
multi-select-grouped.component.ts
import { Component } from '@angular/core';
import { BpdmMultiSelect, type SelectItems } from '@bpdm/ng';

@Component({
  selector: 'app-grouped-cities',
  standalone: true,
  imports: [BpdmMultiSelect],
  template: `<bpdm-multi-select [options]="cities" searchable placeholder="Select cities" />`,
})
export class GroupedCitiesComponent {
  readonly cities: SelectItems = [
    {
      label: 'Europe',
      options: [
        { value: 'lon', label: 'London' },
        { value: 'par', label: 'Paris' },
        { value: 'ber', label: 'Berlin' },
      ],
    },
    {
      label: 'Americas',
      options: [
        { value: 'nyc', label: 'New York' },
        { value: 'sf', label: 'San Francisco' },
        { value: 'tor', label: 'Toronto' },
      ],
    },
  ];
}

Large datasets

The dropdown is virtualized — only visible rows render — so it stays smooth with thousands of options. Combine with searchable to filter.

multi-select-large.tsx
import { MultiSelect } from '@bpdm/ui/multi-select';

const options = Array.from({ length: 10000 }, (_, i) => ({
  value: `item-${i}`,
  label: `Item ${i + 1}`,
}));

export function MultiSelectLarge() {
  return <MultiSelect searchable options={options} placeholder="Pick records" />;
}
multi-select-large.component.ts
import { Component } from '@angular/core';
import { BpdmMultiSelect } from '@bpdm/ng';

@Component({
  selector: 'app-multi-select-large',
  standalone: true,
  imports: [BpdmMultiSelect],
  template: `<bpdm-multi-select [options]="options" searchable placeholder="Pick records" />`,
})
export class MultiSelectLargeComponent {
  readonly options = Array.from({ length: 10000 }, (_, i) => ({
    value: `item-${i}`,
    label: `Item ${i + 1}`,
  }));
}

States

multi-select-states.tsx
import { MultiSelect } from '@bpdm/ui/multi-select';

const frameworks = [
  { value: 'react', label: 'React' },
  { value: 'vue', label: 'Vue' },
  { value: 'angular', label: 'Angular' },
];

export function States() {
  return (
    <div className="flex flex-col gap-3">
      <MultiSelect options={frameworks} placeholder="Default" />
      <MultiSelect options={frameworks} aria-invalid placeholder="Invalid" />
      <MultiSelect options={frameworks} disabled placeholder="Disabled" />
    </div>
  );
}
multi-select-states.component.ts
import { Component } from '@angular/core';
import { BpdmMultiSelect } from '@bpdm/ng';

@Component({
  selector: 'app-states',
  standalone: true,
  imports: [BpdmMultiSelect],
  template: `
    <div class="flex flex-col gap-3">
      <bpdm-multi-select [options]="frameworks" placeholder="Default" />
      <bpdm-multi-select [options]="frameworks" aria-invalid="true" placeholder="Invalid" />
      <bpdm-multi-select [options]="frameworks" disabled placeholder="Disabled" />
    </div>
  `,
})
export class StatesComponent {
  readonly frameworks = [
    { value: 'react', label: 'React' },
    { value: 'vue', label: 'Vue' },
    { value: 'angular', label: 'Angular' },
  ];
}

Controlled value

value is an array of selected values; drive it with onValueChange (React) or two-way [(value)] (Angular).

multi-select-controlled.tsx
import { useState } from 'react';
import { MultiSelect } from '@bpdm/ui/multi-select';

export function Controlled({ frameworks }) {
  const [value, setValue] = useState<string[]>(['react']);
  return <MultiSelect options={frameworks} value={value} onValueChange={setValue} />;
}
multi-select-controlled.component.ts
import { Component } from '@angular/core';
import { BpdmMultiSelect } from '@bpdm/ng';

@Component({
  selector: 'app-controlled-multi',
  standalone: true,
  imports: [BpdmMultiSelect],
  template: `<bpdm-multi-select [options]="frameworks" [(value)]="selected" />`,
})
export class ControlledMultiComponent {
  selected: string[] = ['react'];
  readonly frameworks = [
    { value: 'react', label: 'React' },
    { value: 'vue', label: 'Vue' },
    { value: 'angular', label: 'Angular' },
  ];
}

API

PropTypeDefaultDescription
optionsSelectItems (flat or grouped)Required. Data-driven options.
value / defaultValuestring[]Controlled / uncontrolled selection.
onValueChange(value: string[]) => voidReact — fires on change. Angular uses [(value)].
placeholderstringSelect…Text when nothing is selected.
maxDisplaynumber3Chips shown before "+N"; 0 → "N selected" count.
selectAllbooleantrueShow a "Select all" row.
searchablebooleanfalseShow a filter box.
searchPlaceholderstringSearch…Filter placeholder.
emptyTextstringNo results.Shown when the filter matches nothing.
sizesm | md | lgmdTrigger height / text size.
disabledbooleanfalseDisable the control.
aria-invalidbooleanError state.
idstringTrigger id (for <label> association / testing).
aria-label / aria-describedbystringAccessible name / description for the trigger + option list.
messages{ selectAll, clearAll, remove(label), selected(count) }EnglishOverride the built-in labels + count text for i18n.

maxHeight (px) caps the list; in React contentClassName styles the panel.

Accessibility

  • Full WAI-ARIA combobox + multi-selectable listbox wiring: the trigger is role="combobox" with aria-haspopup="listbox", aria-expanded, and aria-controls; the panel is role="listbox" aria-multiselectable="true" and each option is role="option" with aria-selected.
  • The active option is exposed via aria-activedescendant on the focused element (the search box when searchable, otherwise the listbox), so screen readers announce each row as you arrow — even virtualized. Group headers are aria-hidden.
  • Select all is a tri-state role="checkbox" (aria-checked = true / mixed / false); each chip's remove button and the clear-all button carry descriptive aria-labels.
  • Keyboard: arrows move, Enter toggles, Esc closes; searchable makes the filter an editable combobox. Give it a name via <label> (id) or aria-label; set aria-invalid on error.

Internationalization

  • All built-in copy is translatable: placeholder / searchPlaceholder / emptyText are props, and the messages prop overrides selectAll, clearAll, the per-chip remove(label) label, and the selected(count) count text (e.g. "3 selected"). Option labels are your data.
  • RTL-safe: options use logical alignment (text-start); chips, the count, and the chevron lay out with flex, so everything mirrors under dir="rtl". See the Internationalization guide.

On this page