{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "user-selector", "title": "UserSelector", "dependencies": [ "lucide-react@1.14.0" ], "registryDependencies": [ "@birchline/avatar", "@birchline/badge", "@birchline/checkbox", "@birchline/command", "@birchline/popover", "@birchline/tokens", "@birchline/utils" ], "files": [ { "path": "src/components/ui/user-selector.tsx", "content": "import { useState, useMemo } from \"react\"\nimport { Check, UserPlus, UserRound, X } from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\nimport { Popover, PopoverContent, PopoverTrigger } from \"@/components/ui/popover\"\nimport { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem, CommandSeparator } from \"@/components/ui/command\"\nimport { Avatar, AvatarFallback } from \"@/components/ui/avatar\"\nimport { Badge } from \"@/components/ui/badge\"\nimport { Checkbox } from \"@/components/ui/checkbox\"\n\nfunction HighlightMatch({ text, query }: { text: string; query: string }) {\n if (!query || query.length < 1) return <>{text}\n const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\")})`, \"gi\")\n const parts = text.split(regex)\n return (\n <>\n {parts.map((part, i) =>\n regex.test(part) ? (\n {part}\n ) : (\n part\n )\n )}\n \n )\n}\n\nexport type UserRole = \"Admin\" | \"Collaborator\" | \"Guest\"\n\nexport interface User {\n id: string\n name: string\n email: string\n initials: string\n role: UserRole\n}\n\ninterface UserSelectorProps {\n users: User[]\n recentIds?: string[]\n value?: string | null\n onSelect?: (userId: string | null) => void\n multiSelect?: boolean\n values?: string[]\n onValuesChange?: (userIds: string[]) => void\n onInvite?: () => void\n placeholder?: string\n noAssigneeLabel?: string\n showNoAssignee?: boolean\n clearable?: boolean\n className?: string\n}\n\nconst roleBadgeVariant: Record = {\n Admin: \"accent\",\n Collaborator: \"accent\",\n Guest: \"muted\",\n}\n\nfunction getRoleAvatarColor(role: UserRole) {\n return role === \"Guest\" ? \"bg-gray-500 text-primary-foreground\" : \"bg-primary text-primary-foreground\"\n}\n\nfunction UserRow({\n user,\n selected,\n onSelect,\n search,\n multiSelect,\n}: {\n user: User\n selected: boolean\n onSelect: () => void\n search?: string\n multiSelect?: boolean\n}) {\n return (\n \n \n {multiSelect ? (\n \n ) : (\n selected && \n )}\n \n \n \n {user.initials}\n \n \n
\n
\n \n \n {user.role}\n \n
\n
\n
\n \n )\n}\n\nfunction UserSelector({\n users,\n recentIds = [],\n value,\n onSelect,\n multiSelect = false,\n values = [],\n onValuesChange,\n onInvite,\n placeholder = \"Select user…\",\n noAssigneeLabel = \"No Assignee\",\n showNoAssignee = true,\n clearable = false,\n className,\n}: UserSelectorProps) {\n const [open, setOpen] = useState(false)\n const [search, setSearch] = useState(\"\")\n\n const selectedUser = useMemo(\n () => users.find((u) => u.id === value) ?? null,\n [users, value]\n )\n\n const selectedUsers = useMemo(\n () => users.filter((u) => values.includes(u.id)),\n [users, values]\n )\n\n const recentUsers = useMemo(\n () => recentIds.map((id) => users.find((u) => u.id === id)).filter(Boolean) as User[],\n [users, recentIds]\n )\n\n const nonRecentUsers = useMemo(\n () => users.filter((u) => !recentIds.includes(u.id)),\n [users, recentIds]\n )\n\n function handleSelect(userId: string | null) {\n onSelect?.(userId)\n setOpen(false)\n setSearch(\"\")\n }\n\n function handleMultiToggle(userId: string) {\n const next = values.includes(userId)\n ? values.filter((id) => id !== userId)\n : [...values, userId]\n onValuesChange?.(next)\n }\n\n return (\n { setOpen(v); if (!v) setSearch(\"\") }}>\n \n \n {multiSelect ? (\n selectedUsers.length > 0 ? (\n <>\n
\n {selectedUsers.slice(0, 3).map((u) => (\n \n \n {u.initials}\n \n \n ))}\n
\n \n {selectedUsers.length === 1 ? selectedUsers[0].name : `${selectedUsers.length} users`}\n \n {clearable && (\n { e.stopPropagation(); onValuesChange?.([]) }}\n className=\"ml-auto text-muted-foreground hover:text-foreground transition-colors\"\n >\n \n \n )}\n \n ) : (\n <>\n
\n \n
\n {noAssigneeLabel}\n \n )\n ) : selectedUser ? (\n <>\n \n \n {selectedUser.initials}\n \n \n {selectedUser.name}\n {clearable && (\n { e.stopPropagation(); onSelect?.(null) }}\n className=\"ml-auto text-muted-foreground hover:text-foreground transition-colors\"\n >\n \n \n )}\n \n ) : value === null ? (\n <>\n
\n \n
\n {noAssigneeLabel}\n \n ) : (\n {placeholder}\n )}\n \n
\n \n {\n return value.toLowerCase().includes(search.toLowerCase()) ? 1 : 0\n }}>\n
\n \n {search.length > 0 && (\n setSearch(\"\")}\n className=\"absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground\"\n >\n \n \n )}\n
\n \n No users found.\n {!search && showNoAssignee && (\n \n { if (multiSelect) { onValuesChange?.([]); setOpen(false); setSearch(\"\") } else { handleSelect(null) } }}\n className=\"flex items-center gap-3 px-3 py-2.5\"\n >\n \n {value === null && }\n \n
\n \n
\n No Assignee\n \n
\n )}\n {recentUsers.length > 0 && (\n <>\n {!search && showNoAssignee && }\n \n {recentUsers.map((user) => (\n multiSelect ? handleMultiToggle(user.id) : handleSelect(user.id)}\n search={search}\n multiSelect={multiSelect}\n />\n ))}\n \n \n )}\n {nonRecentUsers.length > 0 && (\n <>\n {((!search && showNoAssignee) || recentUsers.length > 0) && }\n \n {nonRecentUsers.map((user) => (\n multiSelect ? handleMultiToggle(user.id) : handleSelect(user.id)}\n search={search}\n multiSelect={multiSelect}\n />\n ))}\n \n \n )}\n
\n {onInvite && !search && (\n
\n \n { onInvite(); setOpen(false) }}\n className=\"flex items-center gap-3 px-3 py-1\"\n >\n \n
\n \n
\n Invite User\n \n
\n
\n )}\n
\n \n
\n )\n}\n\nexport { UserSelector }\n", "type": "registry:ui", "target": "components/ui/user-selector.tsx" } ], "type": "registry:ui" }