{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "people-picker",
  "type": "registry:component",
  "title": "People Picker",
  "description": "Find people and teams in one grouped list, see what you typed highlighted in each match, add as many as you like as springy avatar chips, or create someone new right there.",
  "categories": [
    "component",
    "Forms"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://coss.com/ui/r/avatar.json",
    "https://coss.com/ui/r/combobox.json"
  ],
  "files": [
    {
      "path": "registry/devl/people-picker.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport { PlusIcon, SearchIcon, XIcon } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { Fragment, type ReactNode, useId, useMemo, useState } from \"react\";\nimport { Avatar, AvatarFallback, AvatarImage } from \"@/components/ui/avatar\";\nimport {\n  Combobox,\n  ComboboxChips,\n  ComboboxChipsInput,\n  ComboboxCollection,\n  ComboboxEmpty,\n  ComboboxGroup,\n  ComboboxGroupLabel,\n  ComboboxItem,\n  ComboboxList,\n  ComboboxPopup,\n  ComboboxPrimitive,\n} from \"@/components/ui/combobox\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface PickerOption {\n  id: string;\n  label: string;\n  hint?: string;\n  avatar?: string;\n  group: string;\n}\n\ntype Row = PickerOption & { create?: boolean };\ntype Group = { value: string; items: Row[] };\n\nconst TINTS = [\"bg-sky-700\", \"bg-violet-700\", \"bg-amber-700\", \"bg-emerald-700\", \"bg-rose-700\"];\nconst tintFor = (id: string) => TINTS[[...id].reduce((h, c) => (h * 31 + c.charCodeAt(0)) >>> 0, 7) % TINTS.length];\nconst initials = (label: string) =>\n  label\n    .split(/\\s+/)\n    .map((w) => w[0])\n    .join(\"\")\n    .slice(0, 2)\n    .toUpperCase();\n\nfunction OptionAvatar({ option, className }: { option: PickerOption; className?: string }) {\n  return (\n    <Avatar className={cn(\"size-5\", className)}>\n      {option.avatar && <AvatarImage alt=\"\" src={option.avatar} />}\n      <AvatarFallback className={cn(\"font-semibold text-[9px] text-white\", tintFor(option.id))}>\n        {initials(option.label)}\n      </AvatarFallback>\n    </Avatar>\n  );\n}\n\nfunction Highlight({ text, query }: { text: string; query: string }) {\n  const q = query.trim();\n  if (!q) return <>{text}</>;\n  const at = text.toLowerCase().indexOf(q.toLowerCase());\n  if (at < 0) return <>{text}</>;\n  return (\n    <>\n      {text.slice(0, at)}\n      <mark className=\"bg-transparent font-semibold text-foreground\">{text.slice(at, at + q.length)}</mark>\n      {text.slice(at + q.length)}\n    </>\n  );\n}\n\nexport interface PeoplePickerProps {\n  options: PickerOption[];\n  defaultValue?: PickerOption[];\n  value?: PickerOption[];\n  onValueChange?: (value: PickerOption[]) => void;\n  multiple?: boolean;\n  createGroup?: string | false;\n  placeholder?: string;\n  label?: ReactNode;\n  className?: string;\n}\n\nexport function PeoplePicker({\n  options: initialOptions,\n  defaultValue = [],\n  value: valueProp,\n  onValueChange,\n  multiple = true,\n  createGroup = \"New\",\n  placeholder = \"Search people or teams…\",\n  label,\n  className,\n}: PeoplePickerProps) {\n  const [options, setOptions] = useState(initialOptions);\n  const [inner, setInner] = useState<PickerOption[]>(defaultValue);\n  const value = valueProp ?? inner;\n  const [query, setQuery] = useState(\"\");\n  const reduce = useReducedMotion();\n  const labelId = useId();\n\n  const groups = useMemo(() => {\n    const q = query.trim().toLowerCase();\n    const byGroup = new Map<string, Row[]>();\n    for (const option of options) {\n      const hit = !q || option.label.toLowerCase().includes(q) || option.hint?.toLowerCase().includes(q);\n      if (!hit) continue;\n      byGroup.set(option.group, [...(byGroup.get(option.group) ?? []), option]);\n    }\n    const list: Group[] = [...byGroup].map(([g, items]) => ({ value: g, items }));\n    const exact = options.some((o) => o.label.toLowerCase() === q);\n    if (createGroup && q && !exact) {\n      const label = query.trim();\n      list.push({\n        value: \"__create\",\n        items: [{ id: `__create:${label}`, label, group: createGroup, create: true }],\n      });\n    }\n    return list;\n  }, [options, query, createGroup]);\n\n  const commit = (next: PickerOption[]) => {\n    if (valueProp === undefined) setInner(next);\n    onValueChange?.(next);\n  };\n\n  const onChange = (next: Row[], details?: { reason?: string }) => {\n    // Escape on a closed list would wipe every chip; only let it clear the typed text.\n    if (details?.reason === \"escape-key\") return;\n    let rows = next;\n    const created = rows.find((r) => r.create);\n    if (created) {\n      const option: PickerOption = {\n        id: `new-${created.label.toLowerCase().replace(/\\s+/g, \"-\")}-${options.length}`,\n        label: created.label,\n        group: created.group,\n        hint: \"Invite pending\",\n      };\n      setOptions((list) => [...list, option]);\n      rows = rows.map((r) => (r.create ? option : r));\n    }\n    const clean = rows.map(({ create: _c, ...rest }) => rest as PickerOption);\n    commit(multiple ? clean : clean.slice(-1));\n    setQuery(\"\");\n  };\n\n  const remove = (id: string) => commit(value.filter((v) => v.id !== id));\n\n  return (\n    <div className={cn(\"grid w-full max-w-sm gap-2\", className)}>\n      {label && (\n        <span className=\"font-medium text-sm\" id={labelId}>\n          {label}\n        </span>\n      )}\n      <Combobox<Row, true>\n        filteredItems={groups}\n        inputValue={query}\n        isItemEqualToValue={(a, b) => a.id === b.id}\n        itemToStringLabel={(item) => item.label}\n        items={groups}\n        multiple\n        onInputValueChange={setQuery}\n        onValueChange={onChange}\n        value={value}\n        autoHighlight\n      >\n        <ComboboxChips aria-labelledby={label ? labelId : undefined} className=\"min-h-10 sm:min-h-9\" startAddon={<SearchIcon />}>\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            {value.map((option) => (\n              <ComboboxPrimitive.Chip\n                aria-label={option.label}\n                data-slot=\"combobox-chip\"\n                className=\"flex items-center gap-1.5 rounded-full bg-accent ps-0.5 pe-0.5 font-medium text-accent-foreground text-xs outline-none data-highlighted:ring-2 data-highlighted:ring-ring\"\n                key={option.id}\n                render={\n                  <motion.div\n                    animate={{ opacity: 1, scale: 1 }}\n                    exit={{ opacity: 0, scale: 0.9, transition: { duration: 0.12 } }}\n                    initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.9 }}\n                    layout={!reduce}\n                    transition={{ type: \"spring\", stiffness: 600, damping: 40 }}\n                  />\n                }\n              >\n                <OptionAvatar option={option} />\n                <span className=\"max-w-28 truncate\">{option.label}</span>\n                <ComboboxPrimitive.ChipRemove\n                  aria-label={`Remove ${option.label}`}\n                  className=\"relative flex size-5 cursor-pointer items-center justify-center rounded-full text-muted-foreground after:absolute after:-inset-1.5 hover:bg-foreground/8 hover:text-foreground\"\n                >\n                  <XIcon className=\"size-3\" />\n                </ComboboxPrimitive.ChipRemove>\n              </ComboboxPrimitive.Chip>\n            ))}\n          </AnimatePresence>\n          <ComboboxChipsInput aria-label={typeof label === \"string\" ? label : \"Search\"} placeholder={value.length ? \"\" : placeholder} />\n        </ComboboxChips>\n        <ComboboxPopup className=\"w-(--anchor-width)\">\n          <ComboboxEmpty>No matches.</ComboboxEmpty>\n          <ComboboxList>\n            {(group: Group) => (\n              <Fragment key={group.value}>\n                <ComboboxGroup items={group.items}>\n                  {group.value !== \"__create\" && <ComboboxGroupLabel>{group.value}</ComboboxGroupLabel>}\n                  <ComboboxCollection>\n                    {(item: Row) =>\n                      item.create ? (\n                        <ComboboxItem key={item.id} value={item}>\n                          <span className=\"flex items-center gap-2\">\n                            <span className=\"flex size-5 items-center justify-center rounded-full border border-dashed border-ring text-muted-foreground\">\n                              <PlusIcon className=\"size-3\" />\n                            </span>\n                            <span>\n                              Create <span className=\"font-medium\">“{item.label}”</span>\n                            </span>\n                          </span>\n                        </ComboboxItem>\n                      ) : (\n                        <ComboboxItem key={item.id} value={item}>\n                          <span className=\"flex items-center gap-2\">\n                            <OptionAvatar option={item} />\n                            <span className=\"truncate\">\n                              <Highlight query={query} text={item.label} />\n                            </span>\n                            {item.hint && (\n                              <span className=\"ms-auto truncate ps-2 text-muted-foreground text-xs\">\n                                <Highlight query={query} text={item.hint} />\n                              </span>\n                            )}\n                          </span>\n                        </ComboboxItem>\n                      )\n                    }\n                  </ComboboxCollection>\n                </ComboboxGroup>\n              </Fragment>\n            )}\n          </ComboboxList>\n        </ComboboxPopup>\n      </Combobox>\n    </div>\n  );\n}\n"
    }
  ]
}