bpdm/ui
Form

Select

A single-select dropdown — data-driven, searchable, accessible, in React (@bpdm/ui) and Angular (@bpdm/ng).

The Select is a single-choice dropdown. It's data-driven — you pass an options array (flat or grouped) rather than markup — with optional search, controlled or uncontrolled value, and long lists virtualized for performance.

Usage

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

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

export function SelectUsage() {
  return <Select options={FRAMEWORKS} placeholder="Select a framework" />;
}
select-usage.ts
import { Component } from '@angular/core';
import { BpdmSelect } from '@bpdm/ng';

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

Searchable

Set searchable to add a filter box — handy for long lists. Customise the empty state with emptyText and the field with searchPlaceholder.

select-searchable.tsx
import { Select } from '@bpdm/ui/select';

export function SelectSearchable({ frameworks }) {
  return <Select searchable options={frameworks} placeholder="Search frameworks" />;
}
select-searchable.component.ts
import { Component } from '@angular/core';
import { BpdmSelect } from '@bpdm/ng';

@Component({
  selector: 'app-searchable-select',
  standalone: true,
  imports: [BpdmSelect],
  template: `<bpdm-select [options]="frameworks" searchable placeholder="Search frameworks" />`,
})
export class SearchableSelectComponent {
  readonly frameworks = [
    { value: 'react', label: 'React' },
    { value: 'vue', label: 'Vue' },
    { value: 'angular', label: 'Angular' },
    { value: 'svelte', label: 'Svelte' },
  ];
}

With icons

Each option (and group) accepts an icon — rendered before the label in both the trigger and the dropdown.

select-icons.tsx
import { Circle, CircleDashed, CircleCheck, CircleX } from 'lucide-react';
import { Select } from '@bpdm/ui/select';

const STATUSES = [
  { value: 'todo', label: 'Todo', icon: <Circle className="size-4 text-muted-foreground" /> },
  { value: 'in-progress', label: 'In progress', icon: <CircleDashed className="size-4 text-primary" /> },
  { value: 'done', label: 'Done', icon: <CircleCheck className="size-4 text-primary" /> },
  { value: 'canceled', label: 'Canceled', icon: <CircleX className="size-4 text-destructive" /> },
];

export function SelectWithIcons() {
  return <Select options={STATUSES} placeholder="Set status" />;
}

In Angular, icon takes a string (or any renderable the component supports); pass your status options with an icon per entry:

select-icons.component.ts
import { Component } from '@angular/core';
import { BpdmSelect } from '@bpdm/ng';

@Component({
  selector: 'app-select-icons',
  standalone: true,
  imports: [BpdmSelect],
  template: `<bpdm-select [options]="statuses" placeholder="Set status" />`,
})
export class SelectIconsComponent {
  readonly statuses = [
    { value: 'todo', label: 'Todo', icon: '○' },
    { value: 'in-progress', label: 'In progress', icon: '◐' },
    { value: 'done', label: 'Done', icon: '✓' },
    { value: 'canceled', label: 'Canceled', icon: '✕' },
  ];
}

Grouped options

Pass groups ({ label, options }) instead of flat options to show labelled sections. The group label can include an emoji or icon.

select-groups.tsx
import { Select } from '@bpdm/ui/select';

const CITIES = [
  {
    label: '🇩🇪 Germany',
    options: [
      { value: 'berlin', label: 'Berlin' },
      { value: 'frankfurt', label: 'Frankfurt' },
      { value: 'hamburg', label: 'Hamburg' },
      { value: 'munich', label: 'Munich' },
    ],
  },
  {
    label: '🇺🇸 USA',
    options: [
      { value: 'nyc', label: 'New York' },
      { value: 'la', label: 'Los Angeles' },
      { value: 'chicago', label: 'Chicago' },
    ],
  },
];

export function SelectGroups() {
  return <Select options={CITIES} placeholder="Select a city" />;
}
select-groups.component.ts
import { Component } from '@angular/core';
import { BpdmSelect, type SelectItems } from '@bpdm/ng';

@Component({
  selector: 'app-select-groups',
  standalone: true,
  imports: [BpdmSelect],
  template: `<bpdm-select [options]="cities" placeholder="Select a city" />`,
})
export class SelectGroupsComponent {
  readonly cities: SelectItems = [
    {
      label: '🇩🇪 Germany',
      options: [
        { value: 'berlin', label: 'Berlin' },
        { value: 'frankfurt', label: 'Frankfurt' },
        { value: 'hamburg', label: 'Hamburg' },
        { value: 'munich', label: 'Munich' },
      ],
    },
    {
      label: '🇺🇸 USA',
      options: [
        { value: 'nyc', label: 'New York' },
        { value: 'la', label: 'Los Angeles' },
        { value: 'chicago', label: 'Chicago' },
      ],
    },
  ];
}

Large datasets

The dropdown is virtualized — only the visible rows render — so it stays smooth even with thousands of options. Open it and scroll through 10,000 rows.

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

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

export function SelectLarge() {
  return <Select options={options} placeholder="Scroll 10,000 records" />;
}
select-large.component.ts
import { Component } from '@angular/core';
import { BpdmSelect } from '@bpdm/ng';

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

Sizes

The size prop offers sm, md (default), and lg.

select-sizes.tsx
import { Select } from '@bpdm/ui/select';

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

export function Sizes() {
  return (
    <div className="flex flex-col gap-3">
      <Select size="sm" options={frameworks} placeholder="Small" />
      <Select size="md" options={frameworks} placeholder="Medium" />
      <Select size="lg" options={frameworks} placeholder="Large" />
    </div>
  );
}
select-sizes.component.ts
import { Component } from '@angular/core';
import { BpdmSelect } from '@bpdm/ng';

@Component({
  selector: 'app-sizes',
  standalone: true,
  imports: [BpdmSelect],
  template: `
    <div class="flex flex-col gap-3">
      <bpdm-select size="sm" [options]="frameworks" placeholder="Small" />
      <bpdm-select size="md" [options]="frameworks" placeholder="Medium" />
      <bpdm-select size="lg" [options]="frameworks" placeholder="Large" />
    </div>
  `,
})
export class SizesComponent {
  readonly frameworks = [
    { value: 'react', label: 'React' },
    { value: 'vue', label: 'Vue' },
    { value: 'angular', label: 'Angular' },
  ];
}

States

Set a defaultValue, mark the field with aria-invalid for errors, or disabled to turn it off.

select-states.tsx
import { Select } from '@bpdm/ui/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">
      <Select options={frameworks} placeholder="Default" />
      <Select options={frameworks} defaultValue="react" />
      <Select options={frameworks} aria-invalid placeholder="Invalid" />
      <Select options={frameworks} disabled placeholder="Disabled" />
    </div>
  );
}
select-states.component.ts
import { Component } from '@angular/core';
import { BpdmSelect } from '@bpdm/ng';

@Component({
  selector: 'app-states',
  standalone: true,
  imports: [BpdmSelect],
  template: `
    <div class="flex flex-col gap-3">
      <bpdm-select [options]="frameworks" placeholder="Default" />
      <bpdm-select [options]="frameworks" defaultValue="react" />
      <bpdm-select [options]="frameworks" aria-invalid="true" placeholder="Invalid" />
      <bpdm-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

Drive the selection yourself with value + onValueChange (React) or two-way [(value)] (Angular).

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

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

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

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

API

PropTypeDefaultDescription
optionsSelectItems (flat { value, label, icon?, disabled? }[] or grouped { label, icon?, options }[])Required. Data-driven options; each option/group may carry an icon.
value / defaultValuestringControlled / uncontrolled selection.
onValueChange(value: string) => voidReact — fires on selection. Angular uses two-way [(value)].
placeholderstringSelect…Text when nothing is selected.
searchablebooleanfalseShow a filter box.
searchPlaceholderstringSearch…Placeholder for the filter box.
emptyTextstringNo results.Shown when the filter matches nothing.
sizesm | md | lgmdTrigger height and text size.
disabledbooleanfalseDisable the control.
aria-invalidbooleanError state (destructive border).
idstringTrigger id (for <label> association / testing).
aria-label / aria-describedbystringAccessible name / description for the trigger + option list.

maxHeight (px) caps the scrollable list. In React, contentClassName styles the dropdown panel. Both frameworks accept aria-label / aria-describedby (forwarded to the trigger and used to name the listbox).

Accessibility

  • Full WAI-ARIA combobox + listbox wiring: the trigger is role="combobox" with aria-haspopup="listbox", aria-expanded, and aria-controls pointing at the role="listbox" panel; options are role="option" with aria-selected.
  • The highlighted option is exposed via aria-activedescendant on the focused element (the search box when searchable, otherwise the listbox), so screen readers announce each option as you arrow through — even though the list is virtualized. Group headers are decorative (aria-hidden).
  • Keyboard: / move the active option, Home/End jump to the first/last option, Enter selects, and Esc closes. Type-ahead: when not searchable, typing printable characters focuses the first option starting with what you typed (rapid keystrokes accumulate). With searchable, the filter is an editable combobox (aria-autocomplete="list") and typing filters instead.
  • The active/highlighted option is marked with an amber inline-start bar plus a soft tint and heavier text — a clearly perceptible ≥3:1 focus indicator (not colour alone), mirrored under RTL.
  • Truncated text (a long selected value or option label) carries a native title, so the full text is revealed on hover.
  • Give it a name — pair a <label> (linked via id) or pass aria-label. Set aria-invalid when selection fails validation.

Internationalization

  • All built-in copy is translatable via props: placeholder, searchPlaceholder, and emptyText — pass your localized strings (there's no hard-coded catalogue). Option/group labels are your data, already translated. Give the field a translated aria-label.
  • RTL-safe: options use logical alignment (text-start) and the trigger lays out with justify-between, so the chevron, checkmark, and text mirror under dir="rtl". See the Internationalization guide.

On this page