ComponentPicker
component-pickerComponent-library browser with a category tree, fuzzy search, and a detail pane.
Usage
Basic usage
Category tree on the left, search on top, result grid on the right. The catalog is fed in by the consumer; the component never fetches it.
<ComponentPicker items={items} className="h-[420px]" />Inject a live preview
The library cannot render an instance for an arbitrary slug, so the preview is injected through renderPreview; a placeholder is shown when it is omitted.
<ComponentPicker
items={items}
className="h-[420px]"
defaultActiveSlug="button"
showPreview
showExamples={false}
renderPreview={(item) => (item.slug === "button" ? <Button size="sm">OK</Button> : <Tag>{item.name}</Tag>)}
/>Parse the catalog from llms-full.txt
Parsing is a pure function called on the consumer side; the component sends no network request and assumes no file exists.
import { ComponentPicker, parseComponentCatalog } from "@hulianui/ui";
const items = parseComponentCatalog(await fetch("/llms-full.txt").then((r) => r.text()));
<ComponentPicker items={items} className="h-[320px]" showTree={false} />Command palette form
Use it when you already know which component you want; a category tree and a props table do not fit into a single command line, so this is only a thin wrapper.
const [open, setOpen] = useState(false);
<ComponentPickerCommand items={items} open={open} onOpenChange={setOpen} onSelect={(slug) => insert(slug)} />When to use
Use it when your product needs a component catalog browser: search components, filter by category, read the props table and examples, then hand the chosen slug back. Typical hosts are the component panel of an AI site builder, an internal design-system portal, and the block palette of a low-code editor.
- For a plain quick-jump palette without a category tree or props table, use Command directly, or the
ComponentPickerCommandwrapper exported here. - To pick an icon use IconPicker; to pick an emoji use EmojiPicker.
- For a general list page with a query bar rather than a component catalog, use ProTable.
Three boundaries are deliberate, not unfinished work:
- It never fetches.
itemscomes from the consumer. A library component must not assume the runtime hasllms-full.txt, and must not issue a network request while rendering. Call the pureparseComponentCatalog(text)in your own layer instead. - It never renders an arbitrary component. There is no slug-to-component registry inside the library, and no string evaluation or iframe. Inject a live preview through
renderPreview; without it the preview area shows a placeholder. - It does not pull in fuse.js. The ranker is a pure function in this folder, tuned so that a hit on slug or name outweighs a hit on description. A generic library cannot make that distinction: searching
btnwould float any component whose description happens to scatter b, t, and n above Button.
Import
import {
ALL_CATEGORY_KEY,
ComponentPicker,
ComponentPickerCommand,
buildCategoryTree,
defaultPropsOf,
fuzzyMatch,
matchesCategory,
parseComponentCatalog,
rankComponents,
scoreComponent,
} from "@hulianui/ui"Props
ComponentPickerProps
| Name | Type | Default | Description |
|---|---|---|---|
| items * | ComponentPickerItem[] | — | The catalog. An empty array renders the empty-catalog state, which differs from the no-result state. |
| filter | ComponentPickerFilter | — | Controlled { category?, search? }. Pair it with onFilterChange, otherwise the search box and the tree stop responding. |
| defaultFilter | ComponentPickerFilter | {} | Uncontrolled initial filter. |
| showTree | boolean | true | Shows the category tree on the left. |
| showPreview | boolean | false | Shows the preview area inside the detail pane. |
| showProps | boolean | true | Shows the props table (reuses Table). |
| showExamples | boolean | true | Shows example code (reuses CodeBlock). |
| activeSlug | string | null | — | Controlled highlighted item, the one the detail pane describes. |
| defaultActiveSlug | string | null | null | Uncontrolled initial highlighted item. |
| maxResults | number | 60 | Maximum number of result cards rendered. |
| labels | Partial\<ComponentPickerLabels\> | — | UI copy overrides, whole object or single keys. Omit it and the picker takes its copy from the ConfigProvider locale. |
| className | string | — | Wrapper class. It must establish a height such as h-[560px]; the internal panes then fill it and scroll independently. |
ComponentPickerItem: { slug, name, description, category, group, tags?, props?, examples? }.ComponentPickerProp: { name, type?, default?, description?, required? }.ComponentPickerExample: { title?, lang?, code }.
ComponentPickerCommandProps: items / open / onOpenChange / onSelect / placeholder / emptyMessage / maxResults (default 30) / groupByCategory (default true) / shortcut.
Events
| Event | Type | Description |
|---|---|---|
| onSelect | (slug: string, props: Record\<string, unknown\>) => void | Confirms a choice. The second argument comes from defaultPropsOf(item) and only carries literal defaults; functions and objects are never guessed. |
| onFilterChange | (filter: ComponentPickerFilter) => void | Search text or category changed. This is the only outlet once filter is controlled. |
| onActiveChange | (slug: string | null) => void | Highlighted item changed by click, arrow key, or Escape. |
Slots
| Slot | Type | Description |
|---|---|---|
| renderPreview | (item: ComponentPickerItem) => ReactNode | Injects the live preview. Without it the preview area shows a placeholder rather than blank space. |
Pure functions
| Function | Signature | Description |
|---|---|---|
| parseComponentCatalog | (text: string, options?: ParseCatalogOptions) => ComponentPickerItem[] | Parses llms-full.txt into items. A missing section only drops that field; it never throws. |
| buildCategoryTree | (items, options?) => ComponentPickerCategoryNode[] | Derives the all / category / group tree as plain data with per-level counts. |
| matchesCategory | (item, key?) => boolean | Whether an item falls under a category key; undefined and "*" always match. |
| defaultPropsOf | (item) => Record\<string, unknown\> | Derives initial props from documented default values. |
| rankComponents | (items, query, options?) => RankedComponent[] | Ranks and filters. An empty query keeps the original order. |
| scoreComponent | (item, query) => number | Scores one item; multiple terms are combined with AND. |
| fuzzyMatch | (query, text) => FuzzyMatch | null | Matches one string and returns the score plus matched indices. |
Example
// The catalog comes from llms-full.txt: parsing runs in your layer, the component only displays.
const text = await fetch("/llms-full.txt").then((r) => r.text());
const items = parseComponentCatalog(text);
<ComponentPicker
items={items}
className="h-[560px]"
showPreview
renderPreview={(item) => REGISTRY[item.slug]?.() ?? null}
onSelect={(slug, props) => insertIntoCanvas(slug, props)}
/>// Controlled filter: mirror the search text into the URL query.
const [filter, setFilter] = useState<ComponentPickerFilter>({ category: ALL_CATEGORY_KEY });
<ComponentPicker
items={items}
className="h-[560px]"
filter={filter}
onFilterChange={(next) => {
setFilter(next);
router.replace(`?q=${encodeURIComponent(next.search ?? "")}`);
}}
/>// Palette form, for when the target component is already known.
const [open, setOpen] = useState(false);
<ComponentPickerCommand
items={items}
open={open}
onOpenChange={setOpen}
shortcut
onSelect={(slug) => insertIntoCanvas(slug)}
/>Accessibility
- The search box is a
role="combobox"witharia-controls,aria-autocomplete="list", andaria-activedescendant. The result grid is arole="listbox"and every card is arole="option"witharia-selected. Focus stays in the search box andaria-activedescendantpoints at the highlighted card, which is the WAI-ARIA combobox pattern; focus is never moved between cards. - Keyboard:
DownandUpmove the highlight and wrap around, starting at the first card going down and the last card going up.Enterconfirms.Escapebacks out one level at a time, clearing the query first and the highlight second. The highlighted card is scrolled withscrollIntoView({ block: "nearest" }). - The category tree runs Tree in
expandTrigger="icon"mode, so only the arrow expands a branch and the rest of the row still selects. That is what makes a whole category such asformsselectable. - The detail pane is a
role="region"with an accessible name, so a screen reader can jump straight into it.
Usage notes
- A height is mandatory. Without one in
classNamethe whole block collapses to content height and every independent scroll area stops working, same as Flow and AdminLayout. - A controlled `filter` needs `onFilterChange`. Use
defaultFilterwhen you only want an initial value. Passingfilterwithout the callback leaves the search box unwritable and the tree unclickable, which reads as a broken component. - `renderPreview` is the only preview path, not an optional convenience. The library will not turn a slug into an instance for you. Leave
showPreviewatfalseif you do not want to supply it. - The second argument of `onSelect` may be `{}`.
defaultPropsOfonly accepts literal defaults such astrue,false, numbers, and quoted strings. Documented defaults written as() => void,{...}, or an em dash are skipped on purpose; the empty object is honest, not a parse failure. - `parseComponentCatalog` does not read the slug from the document, because it is not there. The function first harvests cross-reference links across the whole text, so
[Formula](.../components/math)yieldsmath, and only then falls back to kebab-casing the component name. Those two steps cover all 376 entries of this library; the single exception,QRCodetoqrcode, is covered by a built-in override. PassslugOverridesfor the abbreviations in your own docs. - `maxResults` caps rendering, not scoring. Every item is scored before the list is truncated, so entry 61 really is ranked 61st rather than dropped. Raise it to see everything, but 376 cards at once will visibly drop frames.
- UI copy follows the locale by default. Without
labels, every string comes from the ConfigProvider locale and falls back to the built-in Chinese when no provider is present;placeholderandemptyMessageonComponentPickerCommandwork the same way. Priority is prop, then locale, then fallback, so overriding one key leaves the rest tracking the app language. - `ComponentPickerCommand` is not the main form. A command line has no room for a category tree or a props table. Internally it uses
filter={() => true}withonQueryChangeto take over ordering, because the palette's own filter is a substring match that does not rank.
Playground
<ComponentPicker items={items} className="h-[420px]" />