MiHCM
primitives/@mihcm/ui·v0.21.0·stable

OrgSelector

Form-field-shaped picker for hierarchical org structures (departments, teams, business units, locations). Five variants (popover/inline/columns/drawer/command) and four selection modes (single/multi/cascade/include-descendants) compose freely. Full WAI-ARIA TreeView keyboard model, lazy loading, optional virtualization for large trees.

When to use

  • Hierarchical org pickers — departments, teams, business units, cost centres, locations.
  • Complex multi-level trees where users need to drill into a path (Group → Division → Department → Team).
  • Selection scopes for permissions, reporting lines, filters, and audience targeting.
  • Form fields that should feel like a <select> but back a tree.

When NOT to use

Import

import { OrgSelector, type OrgNode } from '@mihcm/ui/OrgSelector';

Modes (selection semantics)

ModeValueBehaviour
singlestring | nullOne node only.
multistring[]Many independent nodes. No propagation.
cascadestring[] (fully expanded)Selecting a parent selects every descendant. Indeterminate state for partial subtrees.
include-descendantsstring[] (compact)Selecting a parent stores only the parent key; the consumer treats it as "this node plus everything beneath".

Variants (visual shells)

VariantWhen
popoverDefault form-field shape — opens a tree below the trigger.
inlineTree is the field. Good for filter sidebars or full-page pickers.
columnsMiller / Finder-style horizontal drill. Big screens, deep trees.
drawerSlides up from the bottom (phones) or in from the side (tablets).
commandSearch-first command palette grouped by ancestor.
breadcrumb-drillOptimized for very deep / very large trees — closed trigger shows the ancestor breadcrumb; open panel hints to drill in level-by-level.

Trigger display modes (triggerDisplay)

How the closed trigger renders the current selection.

ValueClosed-state outputBest for
chips (default)Badge chips with +N more overflowMulti-select with a handful of picks
breadcrumbGroup › Engineering › PlatformSingle-mode pickers where the path matters more than the leaf name
pathGroup / Engineering / PlatformDense forms where a chevron-styled trail would be too busy
count3 selectedMulti-select with dozens of picks
<OrgSelector triggerDisplay="breadcrumb" data={ORG} mode="single" ... />

Pair triggerDisplay="breadcrumb" with variant="breadcrumb-drill" for the deepest-tree workflow.

Scaling to 20k+ nodes

Three knobs handle large trees:

  1. Lazy-loading — supply loadChildren(node) and mark unloaded branches with hasChildren: true. Children are fetched on first expand and cached for the session.
  2. Auto-virtualization — when the total node count crosses autoVirtualizeThreshold (default 1000), the renderer caps the visible window to the first 500 rows and shows a Showing 500 of N — type to narrow. hint. Search and typeahead remain fully responsive.
  3. breadcrumb-drill variant — at this scale, drilling level-by-level beats scrolling a 20k-row tree. The breadcrumb trigger becomes the navigator.

The tree-walk helpers (buildParentMap, flattenVisible, visibleKeysForQuery) are iterative — they will not blow the call stack on pathologically deep trees.

Modes and variants compose freely — <OrgSelector variant="drawer" mode="cascade" /> is a fully valid mobile cascade picker.

Basic usage

const [value, setValue] = useState<string | null>(null);
 
<OrgSelector
  label="Reporting department"
  placeholder="Select department"
  data={ORG}
  mode="single"
  value={value}
  onChange={(v) => setValue((v as string) ?? null)}
/>

Tree data

const ORG: OrgNode[] = [
  {
    key: 'engineering',
    label: 'Engineering',
    description: '120 people',
    children: [
      { key: 'platform', label: 'Platform' },
      { key: 'product-eng', label: 'Product Engineering' },
    ],
  },
];
  • key — stable identifier (used in value, ancestry lookups, lazy loading).
  • label — visible text. Searched against by the built-in typeahead.
  • description — optional muted sub-label (location, headcount, job code…).
  • icon / badge — optional ReactNode slots on either side of the label.
  • children — direct children. Omit and set hasChildren: true for lazy.

Lazy loading

Set hasChildren: true on partial nodes and supply loadChildren:

<OrgSelector
  data={[{ key: 'emea', label: 'EMEA', hasChildren: true }]}
  loadChildren={async (node) => {
    const res = await fetch(`/api/org/${node.key}/children`);
    return res.json();
  }}
/>

loadChildren is called once per node on first expand. Children are appended into the tree and remembered for the session.

Pickable

<OrgSelector pickable="leaf-only" ... />

leaf-only keeps parents expandable but read-only — useful when only leaf teams should ever be picked. Defaults to any-level.

Performance

For trees with more than ~1k flattened visible rows, opt into virtualization:

<OrgSelector virtualized data={largeTree} />

This depends on @tanstack/react-virtual and is gated by the prop so smaller trees stay zero-dep.

Validation

<OrgSelector
  label="Reporting department"
  required
  errorText={value === null ? 'Please pick a department.' : undefined}
  ...
/>

errorText toggles aria-invalid styling. Combine with required to surface the asterisk in the label.

Composition

  • Pair with SectionHeader for filter sidebars.
  • Pair with Form for validation alongside other fields.
  • Use the Drawer variant in mobile filter sheets.