{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "entity-picker", "title": "Entity Picker", "description": "Dialog-based picker for selecting a ContentGrid entity item from a HAL collection.", "dependencies": ["lucide-react"], "registryDependencies": ["button", "dialog", "input", "skeleton", "table", "utils"], "files": [ { "path": "src/patterns/entity-picker/entity-picker.tsx", "content": "import { type ReactNode, useCallback, useState } from \"react\";\nimport { Check, Search } from \"lucide-react\";\nimport { cn, formatCellValue } from \"../../lib/utils\";\nimport { Button } from \"../../primitives/button\";\nimport {\n Dialog,\n DialogContent,\n DialogDescription,\n DialogFooter,\n DialogHeader,\n DialogTitle,\n} from \"../../primitives/dialog\";\nimport { Input } from \"../../primitives/input\";\nimport { Skeleton } from \"../../primitives/skeleton\";\nimport {\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from \"../../primitives/table\";\n\n// ---------------------------------------------------------------------------\n// Public types\n// ---------------------------------------------------------------------------\n\n/** A single selectable option in the picker */\nexport interface EntityPickerOption {\n /** Unique identifier for this option */\n id: string;\n /** Stable href / self-link URI used as the selection value */\n href: string;\n /** Attribute data, keyed by attribute name */\n data: Record;\n}\n\n/** Column descriptor controlling which data fields are shown */\nexport interface EntityPickerColumn {\n /** Attribute name */\n key: string;\n /** Column header label */\n header: string;\n}\n\nexport interface EntityPickerProps {\n /** Controls dialog visibility */\n open: boolean;\n onOpenChange: (open: boolean) => void;\n /** Title used in the dialog heading, e.g. \"Invoice\" */\n relationTitle: string;\n /** Current loaded page of options */\n options: EntityPickerOption[];\n /** Columns to display; when empty the picker falls back to the first data keys */\n columns?: EntityPickerColumn[];\n /** True while options are being fetched */\n isLoading?: boolean;\n /** Current search query — controlled externally so the caller can debounce / fetch */\n searchQuery: string;\n /** Hint text for the search input placeholder */\n searchPlaceholder?: string;\n /** Called when the user types in the search box */\n onSearch: (query: string) => void;\n /** True when a previous page is available */\n hasPreviousPage?: boolean;\n /** True when a next page is available */\n hasNextPage?: boolean;\n /** Called when the user clicks \"Previous\" */\n onPreviousPage?: () => void;\n /** Called when the user clicks \"Next\" */\n onNextPage?: () => void;\n /** Allow selecting multiple items at once */\n multiSelect?: boolean;\n /** Called with the selected href(s) and display label(s) when the user confirms */\n onSelect: (href: string, displayLabel: string) => void;\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\nfunction getItemLabel(item: EntityPickerOption): string {\n const firstVal = Object.entries(item.data).find(\n ([k, v]) => !k.startsWith(\"_\") && k !== \"id\" && v != null,\n );\n return firstVal ? String(firstVal[1]) : item.id;\n}\n\nfunction resolveColumnKeys(\n options: EntityPickerOption[],\n columns?: EntityPickerColumn[],\n): string[] {\n if (columns && columns.length > 0) return columns.map((c) => c.key);\n if (!options[0]) return [];\n return Object.keys(options[0].data)\n .filter((k) => !k.startsWith(\"_\") && k !== \"id\")\n .slice(0, 4);\n}\n\nfunction resolveColumnHeaders(columnKeys: string[], columns?: EntityPickerColumn[]): string[] {\n if (columns && columns.length > 0) return columns.map((c) => c.header);\n return columnKeys;\n}\n\nfunction titleCase(value: string): string {\n return value.replace(/\\b\\w/g, (c) => c.toUpperCase());\n}\n\n/** Stable keys for the loading-state skeleton rows. */\nconst SKELETON_ROWS = [\"s1\", \"s2\", \"s3\"];\n\n// ---------------------------------------------------------------------------\n// Main export\n// ---------------------------------------------------------------------------\n\nexport function EntityPicker({\n open,\n onOpenChange,\n relationTitle,\n options,\n columns,\n isLoading,\n searchQuery,\n searchPlaceholder,\n onSearch,\n hasPreviousPage,\n hasNextPage,\n onPreviousPage,\n onNextPage,\n multiSelect = false,\n onSelect,\n}: Readonly) {\n // Single-select state\n const [selectedHref, setSelectedHref] = useState(null);\n const [selectedLabel, setSelectedLabel] = useState(\"\");\n\n // Multi-select state\n const [selectedItems, setSelectedItems] = useState>(() => new Map());\n\n const toggleItem = useCallback((href: string, label: string) => {\n setSelectedItems((prev) => {\n const next = new Map(prev);\n if (next.has(href)) {\n next.delete(href);\n } else {\n next.set(href, label);\n }\n return next;\n });\n }, []);\n\n function resetState() {\n setSelectedHref(null);\n setSelectedLabel(\"\");\n setSelectedItems(new Map());\n onSearch(\"\");\n }\n\n function handleOpenChange(nextOpen: boolean) {\n if (!nextOpen) resetState();\n onOpenChange(nextOpen);\n }\n\n function handleConfirm() {\n if (multiSelect) {\n for (const [href, label] of selectedItems) {\n onSelect(href, label);\n }\n } else {\n if (!selectedHref) return;\n onSelect(selectedHref, selectedLabel);\n }\n resetState();\n onOpenChange(false);\n }\n\n let selectionCount: number;\n if (multiSelect) {\n selectionCount = selectedItems.size;\n } else {\n selectionCount = selectedHref ? 1 : 0;\n }\n const hasSelection = selectionCount > 0;\n\n let confirmLabel: string;\n if (multiSelect && selectionCount > 1) {\n confirmLabel = `Link ${selectionCount} items`;\n } else if (multiSelect) {\n confirmLabel = \"Link\";\n } else {\n confirmLabel = \"Select\";\n }\n\n const columnKeys = resolveColumnKeys(options, columns);\n const columnHeaders = resolveColumnHeaders(columnKeys, columns);\n\n let resultsBody: ReactNode;\n if (isLoading) {\n resultsBody = (\n
\n {SKELETON_ROWS.map((rowKey) => (\n \n ))}\n
\n );\n } else if (options.length === 0) {\n resultsBody =

No items found.

;\n } else {\n resultsBody = (\n \n \n \n \n {columnHeaders.map((header, i) => (\n {header}\n ))}\n \n \n \n {options.map((item) => {\n const isSelected = multiSelect\n ? selectedItems.has(item.href)\n : selectedHref === item.href;\n return (\n {\n if (multiSelect) {\n toggleItem(item.href, getItemLabel(item));\n } else {\n setSelectedHref(item.href);\n setSelectedLabel(getItemLabel(item));\n }\n }}\n >\n \n {isSelected && }\n \n {columnKeys.map((key) => (\n \n {formatCellValue(item.data[key])}\n \n ))}\n \n );\n })}\n \n
\n );\n }\n\n return (\n \n \n \n \n {multiSelect\n ? `Link ${titleCase(relationTitle)}`\n : `Select ${titleCase(relationTitle)}`}\n \n \n {multiSelect\n ? `Select one or more ${relationTitle.toLowerCase()} to link.`\n : `Choose a ${relationTitle.toLowerCase()} to link.`}\n \n \n\n
\n \n {\n onSearch(e.target.value);\n if (!multiSelect) setSelectedHref(null);\n }}\n className=\"pl-9\"\n />\n
\n\n
{resultsBody}
\n\n {(hasPreviousPage || hasNextPage) && (\n
\n \n Previous\n \n \n
\n )}\n\n \n \n \n \n
\n
\n );\n}\n", "type": "registry:ui" }, { "path": "src/patterns/entity-picker/index.ts", "content": "export { EntityPicker } from \"./entity-picker\";\nexport type { EntityPickerProps, EntityPickerOption, EntityPickerColumn } from \"./entity-picker\";\n", "type": "registry:ui" } ], "type": "registry:ui" }