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
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" />;
}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' },
];
}<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.
import { Select } from '@bpdm/ui/select';
export function SelectSearchable({ frameworks }) {
return <Select searchable options={frameworks} placeholder="Search frameworks" />;
}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.
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:
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.
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" />;
}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.
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" />;
}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.
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>
);
}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.
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>
);
}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).
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} />;
}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
| Prop | Type | Default | Description |
|---|---|---|---|
options | SelectItems (flat { value, label, icon?, disabled? }[] or grouped { label, icon?, options }[]) | — | Required. Data-driven options; each option/group may carry an icon. |
value / defaultValue | string | — | Controlled / uncontrolled selection. |
onValueChange | (value: string) => void | — | React — fires on selection. Angular uses two-way [(value)]. |
placeholder | string | Select… | Text when nothing is selected. |
searchable | boolean | false | Show a filter box. |
searchPlaceholder | string | Search… | Placeholder for the filter box. |
emptyText | string | No results. | Shown when the filter matches nothing. |
size | sm | md | lg | md | Trigger height and text size. |
disabled | boolean | false | Disable the control. |
aria-invalid | boolean | — | Error state (destructive border). |
id | string | — | Trigger id (for <label> association / testing). |
aria-label / aria-describedby | string | — | Accessible 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"witharia-haspopup="listbox",aria-expanded, andaria-controlspointing at therole="listbox"panel; options arerole="option"witharia-selected. - The highlighted option is exposed via
aria-activedescendanton the focused element (the search box whensearchable, 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). Withsearchable, 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 viaid) or passaria-label. Setaria-invalidwhen selection fails validation.
Internationalization
- All built-in copy is translatable via props:
placeholder,searchPlaceholder, andemptyText— pass your localized strings (there's no hard-coded catalogue). Option/grouplabels are your data, already translated. Give the field a translatedaria-label. - RTL-safe: options use logical alignment (
text-start) and the trigger lays out withjustify-between, so the chevron, checkmark, and text mirror underdir="rtl". See the Internationalization guide.