{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "invite-teammates",
  "type": "registry:component",
  "title": "Invite Teammates",
  "description": "Paste a whole list of emails and they sort themselves into chips, each with its own role, with typos caught before anyone gets a broken invite.",
  "categories": [
    "component",
    "Forms"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://coss.com/ui/r/button.json",
    "https://coss.com/ui/r/menu.json",
    "https://coss.com/ui/r/select.json",
    "https://coss.com/ui/r/switch.json"
  ],
  "files": [
    {
      "path": "registry/devl/invite-teammates.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport { CheckIcon, ChevronDownIcon, CircleAlertIcon, CopyIcon, LinkIcon, XIcon } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport {\n  type ClipboardEvent,\n  type KeyboardEvent,\n  useCallback,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from \"@/components/ui/menu\";\nimport { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from \"@/components/ui/select\";\nimport { Switch } from \"@/components/ui/switch\";\nimport { cn } from \"@/lib/utils\";\n\nexport type InviteRole = \"admin\" | \"member\" | \"viewer\";\n\nexport interface Invite {\n  email: string;\n  role: InviteRole;\n}\n\nexport interface WorkspaceMember {\n  name: string;\n  email: string;\n  role: InviteRole | \"owner\";\n}\n\nexport interface InviteTeammatesProps {\n  workspace: string;\n  description?: string;\n  members?: WorkspaceMember[];\n  defaultInvites?: Invite[];\n  defaultRole?: InviteRole;\n  inviteLink?: string;\n  defaultLinkEnabled?: boolean;\n  onSend?: (invites: Invite[]) => void | Promise<void>;\n  onLinkToggle?: (enabled: boolean) => void;\n  onCancel?: () => void;\n  className?: string;\n}\n\nconst roles: { value: InviteRole; label: string; hint: string }[] = [\n  { value: \"admin\", label: \"Admin\", hint: \"Manage billing and people\" },\n  { value: \"member\", label: \"Member\", hint: \"Create and edit everything\" },\n  { value: \"viewer\", label: \"Viewer\", hint: \"Read and comment\" },\n];\nconst roleLabel = (r: InviteRole | \"owner\") => (r === \"owner\" ? \"Owner\" : roles.find((x) => x.value === r)?.label);\n\nconst press = \"transition-[scale,box-shadow,background-color] duration-150 ease-out active:scale-[0.97]\";\nconst EMAIL = /^[^\\s@<>()[\\],;:\"]+@[^\\s@<>()[\\],;:\"]+\\.[a-z]{2,}$/i;\nconst avatarTones = [\n  \"bg-info/16 text-info-foreground\",\n  \"bg-success/16 text-success-foreground\",\n  \"bg-warning/16 text-amber-800 dark:text-warning-foreground\",\n  \"bg-destructive/12 text-destructive-foreground\",\n  \"bg-muted-foreground/16 text-foreground\",\n];\n\nfunction toneFor(text: string) {\n  let h = 0;\n  for (const c of text) h = (h * 31 + c.charCodeAt(0)) >>> 0;\n  return avatarTones[h % avatarTones.length];\n}\n\nfunction splitEmails(text: string): string[] {\n  return text\n    .split(/[\\n,;]+/)\n    .flatMap((segment) => {\n      const angled = segment.match(/<([^>]+)>/);\n      return angled ? [angled[1]] : segment.split(/\\s+/);\n    })\n    .map((part) => part.trim().replace(/^mailto:/i, \"\"))\n    .filter(Boolean);\n}\n\nexport function InviteTeammates({\n  workspace,\n  description,\n  members = [],\n  defaultInvites = [],\n  defaultRole = \"member\",\n  inviteLink,\n  defaultLinkEnabled = false,\n  onSend,\n  onLinkToggle,\n  onCancel,\n  className,\n}: InviteTeammatesProps) {\n  const reduce = useReducedMotion();\n  const fieldId = useId();\n  const hintId = useId();\n  const [invites, setInvites] = useState<Invite[]>(defaultInvites);\n  const [draft, setDraft] = useState(\"\");\n  const [role, setRole] = useState<InviteRole>(defaultRole);\n  const [armed, setArmed] = useState(false);\n  const [flash, setFlash] = useState<string | null>(null);\n  const [sending, setSending] = useState(false);\n  const [sent, setSent] = useState<number | null>(null);\n  const [linkOn, setLinkOn] = useState(defaultLinkEnabled);\n  const [copied, setCopied] = useState(false);\n  const inputRef = useRef<HTMLInputElement>(null);\n  const fieldRef = useRef<HTMLDivElement>(null);\n  const count = useRef(defaultInvites.length);\n  const timers = useRef<ReturnType<typeof setTimeout>[]>([]);\n\n  useEffect(() => () => timers.current.forEach(clearTimeout), []);\n  const later = useCallback((fn: () => void, ms: number) => {\n    timers.current.push(setTimeout(fn, ms));\n  }, []);\n\n  useEffect(() => {\n    if (invites.length > count.current) {\n      fieldRef.current?.scrollTo({ top: fieldRef.current.scrollHeight, behavior: reduce ? \"auto\" : \"smooth\" });\n    }\n    count.current = invites.length;\n  }, [invites.length, reduce]);\n\n  const memberEmails = useMemo(() => new Set(members.map((m) => m.email.toLowerCase())), [members]);\n  const problem = useCallback(\n    (email: string) =>\n      !EMAIL.test(email)\n        ? \"isn't a valid email\"\n        : memberEmails.has(email.toLowerCase())\n          ? \"is already in this workspace\"\n          : null,\n    [memberEmails],\n  );\n\n  const add = (raw: string[]) => {\n    if (!raw.length) return;\n    setSent(null);\n    setInvites((prev) => {\n      const seen = new Set(prev.map((i) => i.email.toLowerCase()));\n      const next = [...prev];\n      for (const email of raw) {\n        const key = email.toLowerCase();\n        if (seen.has(key)) {\n          setFlash(key);\n          later(() => setFlash(null), 600);\n          continue;\n        }\n        seen.add(key);\n        next.push({ email, role });\n      }\n      return next;\n    });\n  };\n\n  const commitDraft = () => {\n    add(splitEmails(draft));\n    setDraft(\"\");\n  };\n\n  const remove = (email: string) => {\n    setInvites((prev) => prev.filter((i) => i.email !== email));\n    inputRef.current?.focus();\n  };\n\n  const edit = (email: string) => {\n    commitDraft();\n    setInvites((prev) => prev.filter((i) => i.email !== email));\n    setDraft(email);\n    requestAnimationFrame(() => inputRef.current?.select());\n  };\n\n  const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {\n    if ([\"Enter\", \",\", \";\", \"Tab\", \" \"].includes(e.key) && draft.trim()) {\n      e.preventDefault();\n      commitDraft();\n      return;\n    }\n    if (e.key === \"Enter\" && !draft && valid.length && !invalid.length) {\n      e.preventDefault();\n      void send();\n      return;\n    }\n    if (e.key === \"Backspace\" && !draft && invites.length) {\n      e.preventDefault();\n      if (armed) {\n        setInvites((prev) => prev.slice(0, -1));\n        setArmed(false);\n      } else setArmed(true);\n      return;\n    }\n    setArmed(false);\n  };\n\n  const onPaste = (e: ClipboardEvent<HTMLInputElement>) => {\n    const text = e.clipboardData.getData(\"text\");\n    const parts = splitEmails(text);\n    if (parts.length < 2 && !/[\\n,;]/.test(text)) return;\n    e.preventDefault();\n    add(parts);\n  };\n\n  const valid = invites.filter((i) => !problem(i.email));\n  const invalid = invites.filter((i) => problem(i.email));\n  const firstInvalid = invalid[0];\n\n  const send = async () => {\n    if (!valid.length || sending) return;\n    setSending(true);\n    await Promise.all([onSend?.(valid), new Promise((r) => later(() => r(null), 700))]);\n    setInvites((prev) => prev.filter((i) => problem(i.email)));\n    setSending(false);\n    setSent(valid.length);\n    later(() => setSent(null), 3200);\n  };\n\n  const copy = async () => {\n    if (!inviteLink) return;\n    try {\n      await navigator.clipboard.writeText(inviteLink);\n    } catch {}\n    setCopied(true);\n    later(() => setCopied(false), 1600);\n  };\n\n  const spring = reduce ? { duration: 0 } : { type: \"spring\" as const, stiffness: 520, damping: 34 };\n  const height = reduce ? { duration: 0 } : { type: \"spring\" as const, stiffness: 360, damping: 34 };\n\n  return (\n    <div\n      aria-labelledby={`${fieldId}-title`}\n      className={cn(\n        \"flex w-full max-w-[460px] flex-col overflow-hidden rounded-2xl border bg-popover text-popover-foreground shadow-lg/5\",\n        className,\n      )}\n      role=\"dialog\"\n    >\n      <div className=\"flex items-start gap-3 px-5 pt-5 pb-4\">\n        <div className=\"min-w-0 flex-1\">\n          <h2 className=\"font-medium text-base\" id={`${fieldId}-title`}>\n            Invite to {workspace}\n          </h2>\n          {description && <p className=\"mt-1 text-muted-foreground text-sm\">{description}</p>}\n        </div>\n        {onCancel && (\n          <Button aria-label=\"Close\" className={cn(\"-me-2 -mt-1\", press)} onClick={onCancel} size=\"icon-sm\" variant=\"ghost\">\n            <XIcon />\n          </Button>\n        )}\n      </div>\n\n      <div className=\"flex flex-col gap-2 px-5\">\n        <div className=\"flex items-center justify-between gap-3\">\n          <label className=\"font-medium text-sm\" htmlFor={fieldId}>\n            Email addresses\n          </label>\n          <div className=\"flex items-center gap-1.5 text-muted-foreground text-xs\">\n            <span className=\"hidden sm:inline\">New invites join as</span>\n            <span className=\"sm:hidden\">Join as</span>\n            <Select\n              items={roles.map((r) => ({ value: r.value, label: r.label }))}\n              onValueChange={(v) => v && setRole(v as InviteRole)}\n              value={role}\n            >\n              <SelectTrigger aria-label=\"Default role for new invites\" className=\"w-auto min-w-0\" size=\"sm\">\n                <SelectValue />\n              </SelectTrigger>\n              <SelectPopup align=\"end\">\n                {roles.map((r) => (\n                  <SelectItem key={r.value} value={r.value}>\n                    {r.label}\n                  </SelectItem>\n                ))}\n              </SelectPopup>\n            </Select>\n          </div>\n        </div>\n\n        <div\n          className={cn(\n            \"relative flex max-h-44 min-h-[88px] cursor-text flex-wrap content-start items-start gap-1.5 overflow-y-auto rounded-lg border border-input bg-background p-1.5 shadow-xs/5 ring-ring/24 transition-shadow dark:bg-input/32\",\n            \"has-[input:focus-visible]:border-ring has-[input:focus-visible]:ring-[3px]\",\n          )}\n          onClick={(e) => {\n            if (e.target === e.currentTarget) inputRef.current?.focus();\n          }}\n          ref={fieldRef}\n        >\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            {invites.map((invite, index) => {\n              const issue = problem(invite.email);\n              const isLast = index === invites.length - 1;\n              return (\n                <motion.span\n                  animate={{ opacity: 1, scale: 1, x: flash === invite.email.toLowerCase() && !reduce ? [0, -3, 3, -2, 0] : 0 }}\n                  className={cn(\n                    \"inline-flex h-7 max-w-full items-center gap-1 rounded-full border ps-1 pe-0.5 text-xs transition-colors\",\n                    issue\n                      ? \"border-destructive/32 bg-destructive/8 text-destructive-foreground dark:bg-destructive/16\"\n                      : \"bg-card text-foreground dark:bg-input/48\",\n                    armed && isLast && \"ring-2 ring-ring\",\n                  )}\n                  exit={{ opacity: 0, scale: 0.95 }}\n                  initial={{ opacity: 0, scale: 0.95 }}\n                  key={invite.email}\n                  layout\n                  transition={{ ...spring, x: { duration: reduce ? 0 : 0.36 } }}\n                >\n                  {issue ? (\n                    <CircleAlertIcon aria-hidden className=\"ms-0.5 size-4 shrink-0\" />\n                  ) : (\n                    <span\n                      aria-hidden\n                      className={cn(\n                        \"flex size-5 shrink-0 items-center justify-center rounded-full font-medium text-[10px] uppercase\",\n                        toneFor(invite.email),\n                      )}\n                    >\n                      {invite.email[0]}\n                    </span>\n                  )}\n                  <button\n                    aria-label={issue ? `${invite.email} ${issue}. Edit` : `Edit ${invite.email}`}\n                    className=\"min-w-0 cursor-pointer truncate rounded-sm px-0.5 outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n                    onClick={() => edit(invite.email)}\n                    type=\"button\"\n                  >\n                    {invite.email}\n                  </button>\n                  {!issue && (\n                    <Menu>\n                      <MenuTrigger\n                        aria-label={`Role for ${invite.email}`}\n                        className=\"inline-flex h-5 shrink-0 cursor-pointer items-center gap-0.5 rounded-full bg-muted px-1.5 text-muted-foreground outline-none dark:bg-background transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring data-popup-open:text-foreground\"\n                      >\n                        {roleLabel(invite.role)}\n                        <ChevronDownIcon aria-hidden className=\"size-3\" />\n                      </MenuTrigger>\n                      <MenuPopup align=\"start\" className=\"w-60\">\n                        <MenuRadioGroup\n                          onValueChange={(v) =>\n                            setInvites((prev) =>\n                              prev.map((i) => (i.email === invite.email ? { ...i, role: v as InviteRole } : i)),\n                            )\n                          }\n                          value={invite.role}\n                        >\n                          {roles.map((r) => (\n                            <MenuRadioItem closeOnClick key={r.value} value={r.value}>\n                              <span className=\"flex flex-col py-0.5\">\n                                <span>{r.label}</span>\n                                <span className=\"text-muted-foreground text-xs\">{r.hint}</span>\n                              </span>\n                            </MenuRadioItem>\n                          ))}\n                        </MenuRadioGroup>\n                      </MenuPopup>\n                    </Menu>\n                  )}\n                  <button\n                    aria-label={`Remove ${invite.email}`}\n                    className={cn(\n                      \"relative flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-full outline-none transition-[color,background-color,scale] duration-150 ease-out after:absolute after:-inset-1.5 focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.9]\",\n                      issue ? \"hover:bg-destructive/16\" : \"text-muted-foreground hover:bg-accent hover:text-foreground\",\n                    )}\n                    onClick={() => remove(invite.email)}\n                    type=\"button\"\n                  >\n                    <XIcon aria-hidden className=\"size-3\" />\n                  </button>\n                </motion.span>\n              );\n            })}\n            <motion.input\n              aria-describedby={hintId}\n              aria-invalid={invalid.length > 0 || undefined}\n              autoComplete=\"off\"\n              className=\"h-7 min-w-36 flex-1 bg-transparent px-1.5 text-sm outline-none placeholder:text-muted-foreground/72\"\n              id={fieldId}\n              key=\"input\"\n              layout=\"position\"\n              onBlur={() => {\n                if (draft.trim()) commitDraft();\n                setArmed(false);\n              }}\n              onChange={(e) => {\n                setDraft(e.target.value);\n                setArmed(false);\n              }}\n              onKeyDown={onKeyDown}\n              onPaste={onPaste}\n              placeholder={invites.length ? \"Add more…\" : \"name@company.com, or paste a list\"}\n              ref={inputRef}\n              spellCheck={false}\n              transition={spring}\n              type=\"text\"\n              value={draft}\n            />\n          </AnimatePresence>\n        </div>\n\n        <div className=\"flex min-h-5 items-center justify-between gap-3 text-xs\" id={hintId}>\n          <AnimatePresence initial={false} mode=\"wait\">\n            {firstInvalid ? (\n              <motion.p\n                animate={{ opacity: 1, y: 0 }}\n                className=\"min-w-0 truncate text-destructive-foreground\"\n                exit={{ opacity: 0, y: -4 }}\n                initial={{ opacity: 0, y: 4 }}\n                key=\"invalid\"\n                transition={{ duration: reduce ? 0 : 0.16 }}\n              >\n                <span className=\"font-medium\">{firstInvalid.email}</span> {problem(firstInvalid.email)}\n                {invalid.length > 1 && ` (+${invalid.length - 1} more)`}\n              </motion.p>\n            ) : sent ? (\n              <motion.p\n                animate={{ opacity: 1, y: 0 }}\n                className=\"flex items-center gap-1 text-success-foreground\"\n                exit={{ opacity: 0, y: -4 }}\n                initial={{ opacity: 0, y: 4 }}\n                key=\"sent\"\n                role=\"status\"\n                transition={{ duration: reduce ? 0 : 0.16 }}\n              >\n                <CheckIcon aria-hidden className=\"size-3.5\" />\n                {sent} {sent === 1 ? \"invite\" : \"invites\"} sent\n              </motion.p>\n            ) : (\n              <motion.p\n                animate={{ opacity: 1, y: 0 }}\n                className=\"text-muted-foreground\"\n                exit={{ opacity: 0, y: -4 }}\n                initial={{ opacity: 0, y: 4 }}\n                key=\"help\"\n                transition={{ duration: reduce ? 0 : 0.16 }}\n              >\n                Separate with commas, spaces or new lines.\n              </motion.p>\n            )}\n          </AnimatePresence>\n          {invalid.length > 0 && (\n            <button\n              className=\"shrink-0 cursor-pointer rounded-sm font-medium text-foreground underline-offset-2 outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring\"\n              onClick={() => setInvites((prev) => prev.filter((i) => !problem(i.email)))}\n              type=\"button\"\n            >\n              Remove invalid\n            </button>\n          )}\n        </div>\n      </div>\n\n      {members.length > 0 && (\n        <div className=\"mt-3 flex flex-col gap-1 px-5\">\n          <p className=\"pb-1 font-medium text-muted-foreground text-xs\">People with access</p>\n          {members.map((m) => (\n            <div className=\"flex items-center gap-2.5 py-1\" key={m.email}>\n              <span\n                aria-hidden\n                className={cn(\n                  \"flex size-7 shrink-0 items-center justify-center rounded-full font-medium text-xs\",\n                  toneFor(m.email),\n                )}\n              >\n                {m.name\n                  .split(\" \")\n                  .map((p) => p[0])\n                  .join(\"\")\n                  .slice(0, 2)}\n              </span>\n              <div className=\"min-w-0 flex-1\">\n                <p className=\"truncate text-sm\">{m.name}</p>\n                <p className=\"truncate text-muted-foreground text-xs\">{m.email}</p>\n              </div>\n              <span className=\"text-muted-foreground text-xs\">{roleLabel(m.role)}</span>\n            </div>\n          ))}\n        </div>\n      )}\n\n      <div className=\"mx-5 mt-4 rounded-xl border bg-card p-3 dark:bg-input/16\">\n        <div className=\"flex items-center gap-3\">\n          <span className=\"flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground\">\n            <LinkIcon aria-hidden className=\"size-4\" />\n          </span>\n          <div className=\"min-w-0 flex-1\">\n            <label className=\"font-medium text-sm\" htmlFor={`${fieldId}-link`}>\n              Invite link\n            </label>\n            <p className=\"text-muted-foreground text-xs\">Anyone with the link joins as a viewer</p>\n          </div>\n          <Switch\n            checked={linkOn}\n            id={`${fieldId}-link`}\n            onCheckedChange={(on) => {\n              setLinkOn(on);\n              onLinkToggle?.(on);\n            }}\n          />\n        </div>\n        <AnimatePresence initial={false}>\n          {linkOn && inviteLink && (\n            <motion.div\n              animate={{ height: \"auto\", opacity: 1 }}\n              className=\"overflow-hidden\"\n              exit={{ height: 0, opacity: 0 }}\n              initial={{ height: 0, opacity: 0 }}\n              transition={height}\n            >\n              <div className=\"mt-3 flex items-center gap-1.5 rounded-lg border border-input bg-background p-1 ps-2.5 dark:bg-input/32\">\n                <span className=\"min-w-0 flex-1 truncate font-mono text-muted-foreground text-xs\">{inviteLink}</span>\n                <Button\n                  aria-label={copied ? \"Link copied\" : \"Copy invite link\"}\n                  className={press}\n                  onClick={copy}\n                  size=\"xs\"\n                  variant=\"outline\"\n                >\n                  <span className=\"relative inline-flex size-3.5 items-center justify-center\">\n                    <AnimatePresence initial={false} mode=\"popLayout\">\n                      <motion.span\n                        animate={{ scale: 1, opacity: 1, filter: \"blur(0px)\" }}\n                        className=\"absolute inset-0 flex items-center justify-center\"\n                        exit={reduce ? { opacity: 0 } : { scale: 0.8, opacity: 0, filter: \"blur(2px)\" }}\n                        initial={reduce ? { opacity: 0 } : { scale: 0.8, opacity: 0, filter: \"blur(2px)\" }}\n                        key={copied ? \"done\" : \"copy\"}\n                        transition={reduce ? { duration: 0 } : { duration: 0.15, ease: [0.23, 1, 0.32, 1] }}\n                      >\n                        {copied ? <CheckIcon className=\"size-3.5 text-success-foreground\" /> : <CopyIcon className=\"size-3.5\" />}\n                      </motion.span>\n                    </AnimatePresence>\n                  </span>\n                  <span className=\"w-11 text-left\">{copied ? \"Copied\" : \"Copy\"}</span>\n                </Button>\n              </div>\n            </motion.div>\n          )}\n        </AnimatePresence>\n      </div>\n\n      <div className=\"mt-5 flex items-center justify-end gap-2 border-t bg-muted/48 px-5 py-3\">\n        {onCancel && (\n          <Button className={press} onClick={onCancel} variant=\"ghost\">\n            Cancel\n          </Button>\n        )}\n        <Button className={press} disabled={!valid.length} loading={sending} onClick={send}>\n          {valid.length ? (\n            <span className=\"inline-flex items-center gap-1\">\n              Send <RollingNumber value={valid.length} /> {valid.length === 1 ? \"invite\" : \"invites\"}\n            </span>\n          ) : (\n            \"Send invites\"\n          )}\n        </Button>\n      </div>\n    </div>\n  );\n}\n\nfunction RollingNumber({ value }: { value: number }) {\n  const reduce = useReducedMotion();\n  return (\n    <span className=\"relative inline-flex h-5 items-center overflow-hidden tabular-nums\">\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        <motion.span\n          animate={{ y: 0, opacity: 1 }}\n          exit={reduce ? { opacity: 0 } : { y: -12, opacity: 0 }}\n          initial={reduce ? { opacity: 0 } : { y: 12, opacity: 0 }}\n          key={value}\n          transition={reduce ? { duration: 0 } : { type: \"spring\", stiffness: 520, damping: 34 }}\n        >\n          {value}\n        </motion.span>\n      </AnimatePresence>\n    </span>\n  );\n}\n"
    }
  ]
}