Entity Picker
alphav0.1.3Searchable picker for typed entities — single or multi select, kind badges, chip cluster with removal, and custom render slots.
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
pnpm dlx shadcn@latest init"registries": {
"@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}pnpm dlx shadcn@latest add @ilinxa/entity-pickerAdd -fixtures for dummy data:
pnpm dlx shadcn@latest add @ilinxa/entity-picker-fixturesPreview
Selected: —
Demo source
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 }. AttachtriggerRefto your root focusable element sofocus()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