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 initpnpm dlx shadcn@latest add @ilinxa/entity-pickerAdd -fixtures for dummy data:
pnpm dlx shadcn@latest add @ilinxa/entity-picker-fixturesCLI can't resolve @ilinxa? The namespace is listed in the official shadcn registry directory, so current CLIs need no configuration. If yours can't resolve it (older or pinned versions, self-hosted mirrors), register it manually in components.json:
"registries": {
"@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}Preview
Selected: —
Demo source
"use client"; import { useRef, useState } from "react";import { ChevronDown, Pin, UserCircle2 } from "lucide-react";import { Badge } from "@/components/ui/badge";import { Button } from "@/components/ui/button";import { Tabs, TabsContent, TabsTrigger } from "@/components/ui/tabs";import { SwipeTabsList } from "@/components/site/swipe-tabs-list";import { EntityPicker } from "./entity-picker";import { GRAPH_NODES, NODE_KINDS, USERS, type GraphNode, type User,} from "./dummy-data";import type { EntityPickerHandle } from "./types"; function DemoFrame({ children }: { children: React.ReactNode }) { return ( <div className="rounded-md border border-border bg-card p-5"> {children} </div> );} function SingleDemo() { const [value, setValue] = useState<GraphNode | null>(null); return ( <DemoFrame> <div className="flex flex-col gap-3"> <label htmlFor="single-picker" className="text-xs text-muted-foreground"> Pick a graph node </label> <EntityPicker<GraphNode> id="single-picker" items={GRAPH_NODES} value={value} onChange={setValue} kinds={NODE_KINDS} triggerLabel="Search nodes…" /> <p className="text-xs text-muted-foreground"> Selected: <span className="font-mono">{value?.label ?? "—"}</span> </p> </div> </DemoFrame> );} function MultiDemo() { const [value, setValue] = useState<GraphNode[]>([ GRAPH_NODES[0], GRAPH_NODES[7], ]); return ( <DemoFrame> <div className="flex flex-col gap-3"> <label htmlFor="multi-picker" className="text-xs text-muted-foreground"> Pick multiple nodes </label> <EntityPicker<GraphNode> id="multi-picker" mode="multi" items={GRAPH_NODES} value={value} onChange={setValue} kinds={NODE_KINDS} triggerLabel="Search nodes…" /> <p className="text-xs text-muted-foreground"> Selected: <span className="font-mono">{value.length}</span> node{value.length === 1 ? "" : "s"} </p> </div> </DemoFrame> );} function NoKindsDemo() { const [value, setValue] = useState<User | null>(null); return ( <DemoFrame> <div className="flex flex-col gap-3"> <label htmlFor="user-picker" className="text-xs text-muted-foreground"> Pick a teammate </label> <EntityPicker<User> id="user-picker" items={USERS} value={value} onChange={setValue} triggerLabel="Search users…" /> <p className="text-xs text-muted-foreground"> {value ? ( <> <span className="font-mono">{value.label}</span> ·{" "} <span className="font-mono">{value.email}</span> </> ) : ( "—" )} </p> </div> </DemoFrame> );} function CustomMatchDemo() { const [value, setValue] = useState<GraphNode[]>([]); return ( <DemoFrame> <div className="flex flex-col gap-3"> <p className="text-xs text-muted-foreground"> Custom match — substring across <code>label</code> AND{" "} <code>description</code>. </p> <EntityPicker<GraphNode> mode="multi" items={GRAPH_NODES} value={value} onChange={setValue} kinds={NODE_KINDS} match={(item, query) => { const q = query.toLowerCase(); return ( item.label.toLowerCase().includes(q) || (item.description?.toLowerCase().includes(q) ?? false) ); }} triggerLabel="Search labels & descriptions…" /> </div> </DemoFrame> );} function CustomItemDemo() { const [value, setValue] = useState<User[]>([]); return ( <DemoFrame> <div className="flex flex-col gap-3"> <p className="text-xs text-muted-foreground"> Custom <code>renderItem</code> — avatar + name + email. </p> <EntityPicker<User> mode="multi" items={USERS} value={value} onChange={setValue} triggerLabel="Pick teammates…" renderItem={(item, ctx) => ( <div className="flex flex-1 items-center gap-2"> <div className="grid size-7 place-items-center rounded-full bg-primary/10 font-mono text-[10px] font-semibold text-primary"> {item.avatar} </div> <div className="flex flex-1 flex-col"> <span className="text-sm">{item.label}</span> <span className="font-mono text-[10px] text-muted-foreground"> {item.email} </span> </div> {ctx.selected ? ( <Badge variant="secondary" className="text-[10px]"> selected </Badge> ) : null} </div> )} /> </div> </DemoFrame> );} function CustomEmptyDemo() { const [value, setValue] = useState<GraphNode | null>(null); return ( <DemoFrame> <div className="flex flex-col gap-3"> <p className="text-xs text-muted-foreground"> Custom <code>renderEmpty</code> — surfaces the query verbatim. </p> <EntityPicker<GraphNode> items={GRAPH_NODES} value={value} onChange={setValue} kinds={NODE_KINDS} triggerLabel="Try typing something nonsense…" renderEmpty={({ query, itemCount }) => ( <div className="flex flex-col items-center gap-1 px-3 py-2 text-center"> <p className="text-xs text-muted-foreground"> {itemCount === 0 ? "No items provided." : `No matches for "${query}".`} </p> {query.length > 0 ? ( <p className="text-[10px] text-muted-foreground/70"> Try a shorter prefix or check the spelling. </p> ) : null} </div> )} /> </div> </DemoFrame> );} function CustomTriggerDemo() { const [value, setValue] = useState<User | null>(null); return ( <DemoFrame> <div className="flex flex-col gap-3"> <p className="text-xs text-muted-foreground"> Custom <code>renderTrigger</code> — host owns the trigger chrome. </p> <EntityPicker<User> items={USERS} value={value} onChange={setValue} triggerLabel="Pick teammate…" renderTrigger={({ value: v, open, triggerRef }) => ( <button ref={(node) => triggerRef(node)} type="button" className="inline-flex items-center gap-2 rounded-md border border-input bg-card px-3 py-2 text-sm transition-colors hover:bg-muted/40" > <UserCircle2 aria-hidden="true" className="size-4" /> <span> {v && !Array.isArray(v) ? v.label : "Pick teammate…"} </span> <ChevronDown aria-hidden="true" className={`size-3 transition-transform ${open ? "rotate-180" : ""}`} /> </button> )} /> </div> </DemoFrame> );} function HandleDemo() { const [value, setValue] = useState<GraphNode[]>([]); const ref = useRef<EntityPickerHandle>(null); return ( <DemoFrame> <div className="flex flex-col gap-3"> <p className="text-xs text-muted-foreground"> Imperative handle — drive the picker from outside. </p> <EntityPicker<GraphNode> ref={ref} mode="multi" items={GRAPH_NODES.filter((n) => n.kind === "person" || n.kind === "project")} value={value} onChange={setValue} kinds={NODE_KINDS} triggerLabel="People & projects…" /> <div className="flex flex-wrap gap-2"> <Button type="button" size="sm" variant="outline" onClick={() => ref.current?.focus()} > focus() </Button> <Button type="button" size="sm" variant="outline" onClick={() => ref.current?.open()} > open() </Button> <Button type="button" size="sm" variant="outline" onClick={() => ref.current?.close()} > close() </Button> <Button type="button" size="sm" variant="outline" onClick={() => ref.current?.clear()} > clear() </Button> </div> {value.length > 0 ? ( <div className="flex flex-wrap gap-1"> {value.map((v) => ( <Badge key={v.id} variant="secondary" className="font-mono text-[10px]" > {v.kind === "person" ? <Pin className="size-2.5" /> : null} {v.label} </Badge> ))} </div> ) : null} </div> </DemoFrame> );} export default function EntityPickerDemo() { return ( <Tabs defaultValue="single"> <SwipeTabsList> <TabsTrigger value="single">Single</TabsTrigger> <TabsTrigger value="multi">Multi</TabsTrigger> <TabsTrigger value="no-kinds">No kinds</TabsTrigger> <TabsTrigger value="custom-match">Custom match</TabsTrigger> <TabsTrigger value="custom-item">Custom item</TabsTrigger> <TabsTrigger value="custom-empty">Custom empty</TabsTrigger> <TabsTrigger value="custom-trigger">Custom trigger</TabsTrigger> <TabsTrigger value="handle">Imperative handle</TabsTrigger> </SwipeTabsList> <TabsContent value="single" className="mt-4"> <SingleDemo /> </TabsContent> <TabsContent value="multi" className="mt-4"> <MultiDemo /> </TabsContent> <TabsContent value="no-kinds" className="mt-4"> <NoKindsDemo /> </TabsContent> <TabsContent value="custom-match" className="mt-4"> <CustomMatchDemo /> </TabsContent> <TabsContent value="custom-item" className="mt-4"> <CustomItemDemo /> </TabsContent> <TabsContent value="custom-empty" className="mt-4"> <CustomEmptyDemo /> </TabsContent> <TabsContent value="custom-trigger" className="mt-4"> <CustomTriggerDemo /> </TabsContent> <TabsContent value="handle" className="mt-4"> <HandleDemo /> </TabsContent> </Tabs> );} 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