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
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

CLI 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

Search nodes…

Selected: —

Demo source

demo.tsxtsx
"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 }. 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