Skip to content
ilinxa/pro-ui

Entity Picker

alphav0.1.3

Searchable picker for typed entities — single or multi select, kind badges, chip cluster with removal, and custom render slots.

Category: FormsUpdated: 2026-08-11Created: 2026-04-29Author: ilinxa

Context

Tier 1 pro-component for the graph-system. Generic over the entity type via `<EntityPicker<T extends EntityLike>>` with mode-aware `value` typing via TypeScript function overloads. Built on shadcn `Command` (cmdk) for search and `Popover` for the dropdown. Composed inside force-graph from v0.3 onward (linking-mode UI) and inside properties-form custom field renderers. Generic standalone: any 'pick one or more typed things' surface. cmdk's a11y wiring + keyboard nav (↑/↓/Enter/Esc) is inherited; multi-mode adds chips with per-chip remove buttons + Backspace-on-empty-search-removes-last-chip.

Installation

Initialize shadcn (once per project)Seeds lib/utils.ts and components.json. Skip if you've already used any shadcn component.
pnpm dlx shadcn@latest init
Register the @ilinxa namespace (once per project)Add to your components.json. Merge with existing config.
"registries": {
  "@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}
Install the component
pnpm dlx shadcn@latest add @ilinxa/entity-picker

Add -fixtures for dummy data:

pnpm dlx shadcn@latest add @ilinxa/entity-picker-fixtures

Preview

Search nodes…

Selected:

Demo source

demo.tsxtsx

Usage

When to use

Reach for EntityPicker whenever a host has a list of typed entities (graph nodes, users, files, organizations, …) and a user needs to pick one or many. Generic over the entity type via <EntityPicker<T extends EntityLike>>; supports single or multi mode with mode-aware value typing via TypeScript function overloads. Built on shadcn Command (cmdk) for search and Popover for the dropdown.

Basic single

import { EntityPicker } from "@/components/entity-picker";

interface Node {
  id: string;
  label: string;
  kind: "person" | "project";
}

const NODES: Node[] = [/* ... */];

export function NodePicker() {
  const [value, setValue] = useState<Node | null>(null);
  return (
    <EntityPicker<Node>
      items={NODES}
      value={value}
      onChange={setValue}
      triggerLabel="Pick a node"
    />
  );
}

Multi

Set mode="multi". value becomes T[]; chips render in the trigger; Backspace on empty search removes the last chip; chip-X buttons remove individual chips. Selection order is the order picked (no drag-to-reorder in v0.1).

<EntityPicker<Node>
  mode="multi"
  items={NODES}
  value={selected}
  onChange={setSelected}
/>

Kinds + badges

When items carry a kind, supply a kinds: Record<string, KindMeta> map keyed by kind value. Each KindMeta has a label and an optional color (CSS variable name or OKLCH literal). Badges render in result rows + chips; toggle with showKindBadges (defaults to true iff any item has a kind).

Custom match

Default match is case-insensitive substring on item.label via String.toLowerCase(). Pass match: (item, query) => boolean for richer search (e.g., search across description, fuzzy-rank via Fuse.js, or Intl.Collator for accent-insensitive matching). Filter cost runs on every keystroke; keep predicates cheap.

Custom slots

  • renderItem(item, ctx) — replace the result row body. ctx has { selected, query }. The CommandItem chrome (highlight, click handler) is preserved.
  • renderTrigger(ctx) — replace the trigger entirely. ctx has { value, open, triggerRef }. Attach triggerRef to your root focusable element so focus() ref method works.
  • renderEmpty(ctx) — replace the empty-state copy. ctx has { query, itemCount }; default is "No results" or "Nothing to pick from".

properties-form integration

When composing entity-picker inside properties-form's custom field renderer, pass id={fieldId} from FieldRendererProps so <label htmlFor={fieldId}> associates correctly.

{
  key: "owner",
  type: "string",  // type ignored when renderer is set
  label: "Owner",
  renderer: ({ value, onChange, fieldId, error, errorId }) => (
    <EntityPicker<User>
      id={fieldId}
      items={users}
      value={value as User | null}
      onChange={onChange}
      triggerLabel="Pick owner"
    />
  ),
}

Imperative handle

const ref = useRef<EntityPickerHandle>(null);
// ...
ref.current?.focus();
ref.current?.open();
ref.current?.close();
ref.current?.clear();

Items reference stability

Inline items={[...]}rebuilds entity references each render and re-derives cmdk's filter index. In-repo, the React Compiler memoizes inline literals at the call site. For NPM consumers without the Compiler, hoist to module scope or wrap with useMemo. A dev-only warning fires after >5 successive unstable renders.

Selection equality

onChange fires only when the id-set of the selection changes. Same-id new-reference values do not fire (avoids spurious re-renders when the host re-derives entity objects upstream). v0.2 will upgrade to ordered-array equality when drag-to-reorder lands — non-breaking.

Keyboard

  • Tab / Shift+Tab — focus trigger and chip-X buttons.
  • Enter / Space / ↓ on trigger — open dropdown.
  • ↑ / ↓ inside search — navigate results (cmdk).
  • Enter on highlighted result — toggle selection.
  • Esc — close dropdown.
  • Backspace on empty search (multi mode) — remove last chip.

What ships in v0.2+

  • Async loadItems(query, page) for paginated remote search.
  • Virtualization for >500-item lists.
  • "Create new" affordance via onCreate(query).
  • Multi-section grouping via groups.
  • Drag-to-reorder chips (multi mode).
  • Fuzzy-rank via rank: (item, query) => number.
  • Ordered-array selection equality (non-breaking).

Features

  • Single OR multi mode via `mode` prop with mode-aware value typing (function overloads)
  • Searchable via shadcn Command (cmdk) with case-insensitive substring default
  • Custom match function for richer search (e.g., search across description)
  • Kind badges via `kinds: Record<string, KindMeta>` map; OKLCH color literals supported
  • Custom render slots — renderItem / renderTrigger / renderEmpty
  • Multi-mode chip cluster with per-chip remove buttons and Backspace-on-empty-search removal
  • Controlled-or-uncontrolled open state mirroring Radix Popover convention
  • id-set selection equality — onChange fires only when selection ids change
  • Field trigger is a real full-field `<button>` overlay beneath a click-transparent chip layer (F-cross-13: no asChild) — chips keep their own nested remove buttons legally
  • Imperative handle — focus / open / close / clear
  • WAI-ARIA 1.2 combobox pattern; cmdk + Radix Popover handle most of the wiring

Tags

entity-pickerpickercomboboxgraph-system

Dependencies

shadcn primitives: badge, command, popover
npm peer deps: lucide-react@^1.11.0