bpdm/ui
Form

Tree Select

Select from a hierarchy with parent/child checkboxes — leaf-based, searchable, in React (@bpdm/ui) and Angular (@bpdm/ng).

The Tree Select picks options from a hierarchy. Toggling a parent selects all its children; selection is leaf-basedvalue is the array of selected leaf values. Nodes are data-driven via options (each node can have children).

Usage

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

const TREE = [
  {
    value: 'frontend',
    label: 'Frontend',
    children: [
      { value: 'react', label: 'React' },
      { value: 'angular', label: 'Angular' },
      { value: 'vue', label: 'Vue' },
    ],
  },
  {
    value: 'backend',
    label: 'Backend',
    children: [
      { value: 'node', label: 'Node.js' },
      { value: 'go', label: 'Go' },
      { value: 'rust', label: 'Rust' },
    ],
  },
];

export function TreeSelectUsage() {
  return <TreeSelect options={TREE} defaultValue={['react', 'vue']} placeholder="Select technologies" />;
}
tree-select-usage.ts
import { Component } from '@angular/core';
import { BpdmTreeSelect } from '@bpdm/ng';

@Component({
  selector: 'tree-select-usage',
  imports: [BpdmTreeSelect],
  templateUrl: './tree-select-usage.html',
})
export class TreeSelectUsage {
  readonly tree = [
    { value: 'frontend', label: 'Frontend', children: [
      { value: 'react', label: 'React' },
      { value: 'angular', label: 'Angular' },
      { value: 'vue', label: 'Vue' },
    ] },
    { value: 'backend', label: 'Backend', children: [
      { value: 'node', label: 'Node.js' },
      { value: 'go', label: 'Go' },
      { value: 'rust', label: 'Rust' },
    ] },
  ];
}
tree-select-usage.html
<bpdm-tree-select [options]="tree" [defaultValue]="['react', 'vue']" placeholder="Select technologies" />

Searchable

Set searchable to filter the tree — matches stay visible with their ancestors expanded.

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

const TREE = [
  { value: 'frontend', label: 'Frontend', children: [
    { value: 'react', label: 'React' },
    { value: 'vue', label: 'Vue' },
  ] },
  { value: 'backend', label: 'Backend', children: [
    { value: 'node', label: 'Node.js' },
    { value: 'go', label: 'Go' },
  ] },
];

export function SearchableTree() {
  return <TreeSelect options={TREE} searchable placeholder="Search technologies" />;
}
tree-select-searchable.component.ts
import { Component } from '@angular/core';
import { BpdmTreeSelect } from '@bpdm/ng';

@Component({
  selector: 'app-searchable-tree',
  standalone: true,
  imports: [BpdmTreeSelect],
  template: `<bpdm-tree-select [options]="tree" searchable placeholder="Search technologies" />`,
})
export class SearchableTreeComponent {
  readonly tree = [
    { value: 'frontend', label: 'Frontend', children: [
      { value: 'react', label: 'React' },
      { value: 'vue', label: 'Vue' },
    ] },
    { value: 'backend', label: 'Backend', children: [
      { value: 'node', label: 'Node.js' },
      { value: 'go', label: 'Go' },
    ] },
  ];
}

Count display

Like Multi-select, set maxDisplay={0} to show a compact "N selected" count instead of chips.

tree-select-count.tsx
import { TreeSelect } from '@bpdm/ui/tree-select';

const TREE = [
  { value: 'frontend', label: 'Frontend', children: [
    { value: 'react', label: 'React' },
    { value: 'vue', label: 'Vue' },
  ] },
  { value: 'backend', label: 'Backend', children: [
    { value: 'node', label: 'Node.js' },
    { value: 'go', label: 'Go' },
  ] },
];

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

@Component({
  selector: 'app-count-display',
  standalone: true,
  imports: [BpdmTreeSelect],
  template: `<bpdm-tree-select [options]="tree" [maxDisplay]="0" [defaultValue]="['react', 'vue', 'node']" />`,
})
export class CountDisplayComponent {
  readonly tree = [
    { value: 'frontend', label: 'Frontend', children: [
      { value: 'react', label: 'React' },
      { value: 'vue', label: 'Vue' },
    ] },
    { value: 'backend', label: 'Backend', children: [
      { value: 'node', label: 'Node.js' },
      { value: 'go', label: 'Go' },
    ] },
  ];
}

States

tree-select-states.tsx
import { TreeSelect } from '@bpdm/ui/tree-select';

const TREE = [
  { value: 'frontend', label: 'Frontend', children: [{ value: 'react', label: 'React' }] },
  { value: 'backend', label: 'Backend', children: [{ value: 'node', label: 'Node.js' }] },
];

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

@Component({
  selector: 'app-states',
  standalone: true,
  imports: [BpdmTreeSelect],
  template: `
    <div class="flex flex-col gap-3">
      <bpdm-tree-select [options]="tree" placeholder="Default" />
      <bpdm-tree-select [options]="tree" aria-invalid="true" placeholder="Invalid" />
      <bpdm-tree-select [options]="tree" disabled placeholder="Disabled" />
    </div>
  `,
})
export class StatesComponent {
  readonly tree = [
    { value: 'frontend', label: 'Frontend', children: [{ value: 'react', label: 'React' }] },
    { value: 'backend', label: 'Backend', children: [{ value: 'node', label: 'Node.js' }] },
  ];
}

API

PropTypeDefaultDescription
optionsTreeNode[] ({ value, label, disabled?, children? })Required. The hierarchy.
value / defaultValuestring[]Selected leaf values (controlled / uncontrolled).
onValueChange(value: string[]) => voidReact — fires on change. Angular uses [(value)].
placeholderstringSelect…Text when nothing is selected.
maxDisplaynumber3Chips before "+N"; 0 → "N selected".
selectAllbooleantrueShow a "Select all" row.
searchablebooleanfalseFilter the tree.
searchPlaceholderstringSearch…Filter placeholder.
emptyTextstringNo results.Shown when nothing matches.
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 + tree.
messages{ expand, collapse, selectAll, clearAll, selected(count) }EnglishOverride the built-in labels + count text for i18n.

Accessibility

  • Built on the WAI-ARIA tree pattern: the panel is role="tree" (aria-multiselectable), each node is a role="treeitem" carrying aria-level, aria-expanded (branches), and its chosen state as both aria-selected and aria-checked (true / false / mixed — a parent is mixed when only some descendants are chosen); child nodes sit in a nested role="group". The trigger is role="combobox" with aria-haspopup="tree" and aria-controls. Both state attributes are set so more screen readers surface the state — some read aria-selected, others aria-checked.
  • The caret and the checkbox glyph are decorative (aria-hidden) — state is conveyed by aria-expanded / aria-checked, never a glyph alone. Select all is a tri-state role="checkbox".
  • Fully operable by keyboard. The tree is a single tab stop and tracks the active node with aria-activedescendant: Up / Down move between visible treeitems, Right expands a collapsed branch then steps into it, Left collapses an expanded branch or moves to the parent (both flip under dir="rtl"), Home / End jump to the first / last visible node, Enter / Space toggle selection, type-ahead jumps to the next matching label, and Esc closes. Give the field a name via <label> (id) or aria-label, and set aria-invalid on error.

Internationalization

  • All built-in copy is translatable: placeholder / searchPlaceholder / emptyText are props, and the messages prop overrides the branch expand / collapse labels, selectAll, clearAll, and the selected(count) count text. Node labels are your data.
  • RTL-safe: node indentation uses padding-inline-start, the caret mirrors under dir="rtl", and labels use logical alignment (text-start), so the whole tree flips correctly right-to-left. See the Internationalization guide.

On this page