{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "invite-field",
  "type": "registry:ui",
  "title": "Invite Field",
  "description": "Turns typed or pasted addresses into chips, flags bad ones, sends with a role.",
  "categories": [
    "collaboration"
  ],
  "dependencies": [
    "@base-ui/react",
    "@number-flow/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/invite-field.tsx",
      "content": "\"use client\";\nimport { Select } from \"@base-ui/react/select\";\nimport NumberFlow from \"@number-flow/react\";\nimport { AnimatePresence, motion, useAnimate, useReducedMotion } from \"motion/react\";\nimport { useEffect, useId, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { Check, ChevronDown, Globe, Loader, Warning, X } from \"@/lib/icons\";\nimport { ease, spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\n/* -------------------------------------------------------------------------------------------------\n * Parsing\n * -----------------------------------------------------------------------------------------------*/\n\nconst EMAIL = /^[^\\s@<>()[\\],;:\"]+@[^\\s@<>()[\\],;:\"]+\\.[a-z]{2,}$/i;\n\nexport const isValidEmail = (value: string) => EMAIL.test(value);\n\n/** Lowercases the domain and trims wrapping quotes and brackets: \"<Mara@Northwind.COM>\" → \"Mara@northwind.com\". */\nfunction normalize(token: string) {\n  const t = token.trim().replace(/^[\"'<(]+|[\"'>),.]+$/g, \"\");\n  const at = t.lastIndexOf(\"@\");\n  return at > 0 ? t.slice(0, at + 1) + t.slice(at + 1).toLowerCase() : t;\n}\n\n/**\n * Pulls addresses out of whatever was typed or pasted: comma, semicolon, space or newline\n * separated lists, and contact lists like `Mara Okafor <mara@northwind.com>, jonah@…`,\n * where the names are dropped and only the addresses kept.\n */\nexport function parseEmails(text: string): string[] {\n  const bracketed = [...text.matchAll(/<([^<>\\s]+@[^<>\\s]+)>/g)].map((m) => m[1]);\n  const rest = text.replace(/[^,;\\n]*<[^<>\\s]+@[^<>\\s]+>/g, \" \");\n  const loose = rest\n    .split(/[\\s,;]+/)\n    .map((t) => t.trim())\n    .filter(Boolean);\n  // With a contact list, stray words are names, not failed addresses.\n  const tokens = bracketed.length ? [...bracketed, ...loose.filter((t) => t.includes(\"@\"))] : loose;\n  return [...new Set(tokens.map(normalize).filter(Boolean))];\n}\n\ntype Tone = \"valid\" | \"invalid\" | \"external\";\nconst toneOf = (email: string, domain?: string): Tone =>\n  !isValidEmail(email) ? \"invalid\" : domain && !email.toLowerCase().endsWith(`@${domain.toLowerCase()}`) ? \"external\" : \"valid\";\n\n/* -------------------------------------------------------------------------------------------------\n * InviteField\n * -----------------------------------------------------------------------------------------------*/\n\nexport type InviteRole = { value: string; label: string; description?: string };\n\nexport const defaultInviteRoles: InviteRole[] = [\n  { value: \"viewer\", label: \"Can view\", description: \"Read and download\" },\n  { value: \"commenter\", label: \"Can comment\", description: \"Read and leave comments\" },\n  { value: \"editor\", label: \"Can edit\", description: \"Make changes and share\" },\n];\n\ntype Phase = \"idle\" | \"sending\" | \"sent\";\n\nexport type InviteFieldProps = Omit<React.ComponentProps<\"div\">, \"onChange\" | \"defaultValue\" | \"children\"> & {\n  /** Addresses as chips, controlled. */\n  value?: string[];\n  defaultValue?: string[];\n  onValueChange?: (emails: string[]) => void;\n  /** Roles to choose from. Pass an empty array to hide the role picker. */\n  roles?: InviteRole[];\n  role?: string;\n  defaultRole?: string;\n  onRoleChange?: (role: string) => void;\n  /** Send the invites. A promise keeps the button busy; a rejection keeps the chips and shows why. Omit to hide the button. */\n  onInvite?: (emails: string[], role: string) => void | Promise<unknown>;\n  /** Addresses outside this domain are marked as external, without blocking them. */\n  organizationDomain?: string;\n  /** Most addresses in one invite. */\n  max?: number;\n  label?: string;\n  placeholder?: string;\n  submitLabel?: string;\n  disabled?: boolean;\n  /** Portal target for the role list. Defaults to document.body. */\n  portalContainer?: HTMLElement | null;\n};\n\nexport function InviteField({\n  value: valueProp,\n  defaultValue = [],\n  onValueChange,\n  roles = defaultInviteRoles,\n  role: roleProp,\n  defaultRole,\n  onRoleChange,\n  onInvite,\n  organizationDomain,\n  max,\n  label = \"Invite people\",\n  placeholder = \"Emails, separated by commas\",\n  submitLabel = \"Invite\",\n  disabled = false,\n  portalContainer,\n  className,\n  ...rest\n}: InviteFieldProps) {\n  const reduce = !!useReducedMotion();\n  const uid = useId();\n  const inputId = `${uid}-input`;\n  const hintId = `${uid}-hint`;\n  const [emails, setEmails] = useControllableState({ value: valueProp, defaultValue, onChange: onValueChange });\n  const [role, setRole] = useControllableState({ value: roleProp, defaultValue: defaultRole ?? roles[0]?.value ?? \"\", onChange: onRoleChange });\n  const [draft, setDraft] = useState(\"\");\n  const [phase, setPhase] = useState<Phase>(\"idle\");\n  const [error, setError] = useState<string | null>(null);\n  const [flash, setFlash] = useState<{ email: string; n: number } | null>(null);\n  const [announcement, setAnnouncement] = useState(\"\");\n  const inputRef = useRef<HTMLInputElement>(null);\n  const listRef = useRef<HTMLUListElement>(null);\n  const sentTimer = useRef<number>(undefined);\n  useEffect(() => () => window.clearTimeout(sentTimer.current), []);\n\n  const tones = emails.map((e) => toneOf(e, organizationDomain));\n  const invalid = emails.filter((_, i) => tones[i] === \"invalid\");\n  const external = emails.filter((_, i) => tones[i] === \"external\");\n  const valid = emails.length - invalid.length;\n  const busy = phase === \"sending\";\n  const full = max != null && emails.length >= max;\n\n  /** Adds addresses, skipping ones already there (which flash instead). Returns what was added. */\n  const add = (incoming: string[]) => {\n    if (!incoming.length) return [];\n    const have = new Set(emails.map((e) => e.toLowerCase()));\n    const fresh: string[] = [];\n    let dupe: string | null = null;\n    for (const e of incoming) {\n      const key = e.toLowerCase();\n      if (have.has(key)) {\n        dupe = emails.find((x) => x.toLowerCase() === key) ?? null;\n        continue;\n      }\n      if (max != null && emails.length + fresh.length >= max) break;\n      have.add(key);\n      fresh.push(e);\n    }\n    if (fresh.length) setEmails([...emails, ...fresh]);\n    if (dupe) setFlash((f) => ({ email: dupe!, n: (f?.n ?? 0) + 1 }));\n    setError(null);\n    const bad = fresh.filter((e) => !isValidEmail(e)).length;\n    setAnnouncement(\n      [\n        fresh.length ? `Added ${fresh.length} ${fresh.length === 1 ? \"address\" : \"addresses\"}` : \"\",\n        bad ? `${bad} not valid` : \"\",\n        dupe && !fresh.length ? `${dupe} is already added` : \"\",\n        max != null && emails.length + fresh.length >= max && incoming.length > fresh.length + (dupe ? 1 : 0) ? `Limit of ${max} reached` : \"\",\n      ]\n        .filter(Boolean)\n        .join(\". \"),\n    );\n    return fresh;\n  };\n\n  const commitDraft = () => {\n    const parsed = parseEmails(draft);\n    if (!parsed.length) return false;\n    add(parsed);\n    setDraft(\"\");\n    return true;\n  };\n\n  const remove = (email: string, focusAfter: \"prev\" | \"input\" = \"input\") => {\n    const i = emails.indexOf(email);\n    const next = emails.filter((e) => e !== email);\n    setEmails(next);\n    setError(null);\n    setAnnouncement(`Removed ${email}`);\n    const target = focusAfter === \"prev\" ? (emails[i - 1] ?? emails[i + 1]) : undefined;\n    if (target) chipFor(target)?.focus();\n    else inputRef.current?.focus();\n  };\n\n  // Clicking a chip that needs fixing puts it back in the field to edit.\n  const edit = (email: string) => {\n    const leftover = draft.trim();\n    setEmails(emails.filter((e) => e !== email));\n    setDraft(leftover ? `${leftover}, ${email}` : email);\n    requestAnimationFrame(() => {\n      const el = inputRef.current;\n      el?.focus();\n      el?.setSelectionRange(el.value.length, el.value.length);\n    });\n  };\n\n  // Chips are found by address, not position: one that is still animating out stays in the DOM for a moment.\n  const chipFor = (email: string | undefined) => (email ? (listRef.current?.querySelector<HTMLButtonElement>(`[data-chip][data-email=\"${CSS.escape(email)}\"]`) ?? null) : null);\n  const chipAt = (i: number) => chipFor(emails[i]);\n\n  const send = async () => {\n    if (busy || !onInvite) return;\n    // Whatever is still being typed goes along with the chips.\n    const pending = [...emails];\n    for (const e of parseEmails(draft)) if (!pending.some((p) => p.toLowerCase() === e.toLowerCase())) pending.push(e);\n    if (pending.length !== emails.length) {\n      setEmails(pending);\n      setDraft(\"\");\n    }\n    if (pending.some((e) => !isValidEmail(e))) {\n      const n = pending.filter((e) => !isValidEmail(e)).length;\n      setError(`Fix or remove ${n === 1 ? \"the invalid address\" : `${n} invalid addresses`} to send.`);\n      requestAnimationFrame(() => {\n        const first = listRef.current?.querySelector<HTMLButtonElement>(\"button[data-chip][data-tone=invalid]\");\n        first?.focus();\n      });\n      return;\n    }\n    if (!pending.length) return;\n    setPhase(\"sending\");\n    setError(null);\n    try {\n      await onInvite(pending, role);\n      setEmails([]);\n      setPhase(\"sent\");\n      setAnnouncement(`Invited ${pending.length} ${pending.length === 1 ? \"person\" : \"people\"}`);\n      sentTimer.current = window.setTimeout(() => setPhase(\"idle\"), 1600);\n    } catch {\n      setPhase(\"idle\");\n      setError(\"Couldn’t send the invites. Try again.\");\n    }\n  };\n\n  const hint =\n    error ??\n    (invalid.length\n      ? `${invalid.length === 1 ? \"1 address isn’t\" : `${invalid.length} addresses aren’t`} valid. Click to edit.`\n      : external.length && organizationDomain\n        ? `${external.length === 1 ? \"1 person is\" : `${external.length} people are`} outside ${organizationDomain}.`\n        : full\n          ? `You can invite up to ${max} people at a time.`\n          : null);\n  const hintTone = error || invalid.length ? \"danger\" : external.length ? \"warning\" : \"muted\";\n\n  return (\n    <div className={cn(\"@container flex w-full min-w-0 flex-col gap-1.5\", className)} data-disabled={disabled || undefined} {...rest}>\n      {label && (\n        <label htmlFor={inputId} className=\"w-fit text-[12.5px] font-medium text-fg\">\n          {label}\n        </label>\n      )}\n\n      <div className=\"flex min-w-0 items-start gap-2 @max-[22rem]:flex-col @max-[22rem]:items-stretch\">\n        <div className=\"flex min-w-0 flex-1 flex-col gap-1.5\">\n          <div\n            onMouseDown={(e) => {\n              // Presses on the field's empty space land in the input.\n              if (e.target === e.currentTarget || e.target === listRef.current) {\n                e.preventDefault();\n                inputRef.current?.focus();\n              }\n            }}\n            className={cn(\n              \"flex min-h-9 min-w-0 cursor-text items-start rounded-lg border bg-raised shadow-[var(--shadow)]\",\n              \"transition-[border-color,box-shadow] duration-150 ease-out focus-within:ring-3 focus-within:ring-fg/8\",\n              error ? \"border-danger/60\" : \"border-line-2 hover:border-fg-4 focus-within:border-fg-4\",\n              disabled && \"pointer-events-none opacity-50\",\n            )}\n          >\n            <ul ref={listRef} aria-label=\"Addresses\" className=\"flex min-w-0 flex-1 flex-wrap items-center gap-1 p-1 pl-1.5\">\n              <AnimatePresence initial={false} mode=\"popLayout\">\n                {emails.map((email, i) => (\n                  <Chip\n                    key={email}\n                    email={email}\n                    tone={tones[i]}\n                    busy={busy}\n                    reduce={reduce}\n                    flash={flash?.email === email ? flash.n : 0}\n                    onRemove={() => remove(email)}\n                    onEdit={() => edit(email)}\n                    onKeyDown={(e) => {\n                      if (e.key === \"ArrowLeft\") {\n                        e.preventDefault();\n                        chipAt(i - 1)?.focus();\n                      } else if (e.key === \"ArrowRight\") {\n                        e.preventDefault();\n                        (chipAt(i + 1) ?? inputRef.current)?.focus();\n                      } else if (e.key === \"Backspace\" || e.key === \"Delete\") {\n                        e.preventDefault();\n                        remove(email, e.key === \"Backspace\" ? \"prev\" : \"input\");\n                      } else if (e.key === \"Enter\" || e.key === \"F2\") {\n                        e.preventDefault();\n                        edit(email);\n                      } else if (e.key === \"Escape\") {\n                        inputRef.current?.focus();\n                      }\n                    }}\n                  />\n                ))}\n              </AnimatePresence>\n              <li className=\"flex min-w-[9rem] flex-1\">\n                <input\n                  ref={inputRef}\n                  id={inputId}\n                  type=\"text\"\n                  inputMode=\"email\"\n                  autoComplete=\"off\"\n                  autoCapitalize=\"none\"\n                  spellCheck={false}\n                  enterKeyHint={onInvite ? \"send\" : \"done\"}\n                  value={draft}\n                  disabled={disabled || busy}\n                  placeholder={emails.length ? \"\" : placeholder}\n                  aria-label={label ? undefined : \"Email addresses\"}\n                aria-describedby={hint ? hintId : undefined}\n                  aria-invalid={invalid.length > 0 || undefined}\n                  onChange={(e) => {\n                    const v = e.target.value;\n                    // A separator typed at the end turns what's before it into a chip.\n                    if (/[,;\\s]$/.test(v) && v.trim()) {\n                      add(parseEmails(v));\n                      setDraft(\"\");\n                    } else setDraft(v);\n                    setError(null);\n                  }}\n                  onPaste={(e) => {\n                    const text = e.clipboardData.getData(\"text\");\n                    const found = parseEmails(text);\n                    if (found.length > 1 || /[,;\\n<]/.test(text)) {\n                      e.preventDefault();\n                      add(parseEmails(`${draft} ${text}`));\n                      setDraft(\"\");\n                    }\n                  }}\n                  onBlur={() => {\n                    if (draft.trim()) commitDraft();\n                  }}\n                  onKeyDown={(e) => {\n                    const el = e.currentTarget;\n                    if (e.key === \"Enter\") {\n                      e.preventDefault();\n                      if (!commitDraft() && (e.metaKey || e.ctrlKey || !draft)) void send();\n                    } else if (e.key === \"Tab\" && !e.shiftKey && draft.trim()) {\n                      commitDraft();\n                    } else if (e.key === \"Backspace\" && el.selectionStart === 0 && el.selectionEnd === 0 && emails.length) {\n                      e.preventDefault();\n                      chipAt(emails.length - 1)?.focus();\n                    } else if (e.key === \"ArrowLeft\" && el.selectionStart === 0 && el.selectionEnd === 0 && emails.length) {\n                      e.preventDefault();\n                      chipAt(emails.length - 1)?.focus();\n                    }\n                  }}\n                  className=\"h-7 w-full min-w-0 bg-transparent px-1 text-base text-fg outline-none placeholder:text-fg-4 disabled:cursor-not-allowed sm:text-[13px]\"\n                />\n              </li>\n            </ul>\n\n            {roles.length > 0 && <RolePicker roles={roles} value={role} onValueChange={setRole} disabled={disabled || busy} container={portalContainer} />}\n          </div>\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            {hint && (\n              <motion.p\n                key={hint}\n                id={hintId}\n                role={error ? \"alert\" : undefined}\n                initial={reduce ? { opacity: 0 } : { opacity: 0, y: -3 }}\n                animate={{ opacity: 1, y: 0 }}\n                exit={{ opacity: 0, transition: { duration: 0.08 } }}\n                transition={{ duration: 0.16, ease: ease.out }}\n                className={cn(\"flex items-center gap-1.5 text-[12px]\", hintTone === \"danger\" ? \"text-danger\" : hintTone === \"warning\" ? \"text-warning\" : \"text-fg-3\")}\n              >\n                {hintTone === \"warning\" && <Globe size={13} className=\"shrink-0\" />}\n                {hint}\n              </motion.p>\n            )}\n          </AnimatePresence>\n        </div>\n\n        {onInvite && (\n          <InviteButton\n            phase={phase}\n            count={valid + parseEmails(draft).filter((e) => isValidEmail(e) && !emails.some((x) => x.toLowerCase() === e.toLowerCase())).length}\n            label={submitLabel}\n            disabled={disabled || (emails.length === 0 && !draft.trim())}\n            onPress={() => void send()}\n            reduce={reduce}\n          />\n        )}\n      </div>\n\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {announcement}\n      </span>\n    </div>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Parts\n * -----------------------------------------------------------------------------------------------*/\n\nfunction Chip({\n  email,\n  tone,\n  busy,\n  reduce,\n  flash,\n  onRemove,\n  onEdit,\n  onKeyDown,\n  ref,\n}: {\n  email: string;\n  tone: Tone;\n  busy: boolean;\n  reduce: boolean;\n  flash: number;\n  onRemove: () => void;\n  onEdit: () => void;\n  onKeyDown: (e: React.KeyboardEvent<HTMLButtonElement>) => void;\n  ref?: React.Ref<HTMLLIElement>;\n}) {\n  const [scope, animateChip] = useAnimate<HTMLSpanElement>();\n  // Adding an address that's already here nudges the one that exists instead of adding a twin.\n  useEffect(() => {\n    if (!flash || reduce || !scope.current) return;\n    animateChip(scope.current, { scale: [1, 1.08, 1] }, { duration: 0.32, ease: ease.out });\n  }, [flash, reduce, animateChip, scope]);\n\n  const label = tone === \"invalid\" ? `${email}, not a valid email` : tone === \"external\" ? `${email}, outside your organization` : email;\n\n  return (\n    <motion.li\n      ref={ref}\n      layout={reduce ? false : \"position\"}\n      initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.85, filter: \"blur(2px)\" }}\n      animate={{ opacity: busy ? 0.55 : 1, scale: 1, filter: \"blur(0px)\" }}\n      exit={reduce ? { opacity: 0, transition: { duration: 0.1 } } : { opacity: 0, scale: 0.85, filter: \"blur(2px)\", transition: { duration: 0.12 } }}\n      transition={reduce ? { duration: 0.12 } : { ...spring.pop, opacity: { duration: 0.14 } }}\n      className=\"flex min-w-0 max-w-full\"\n    >\n      <span\n        ref={scope}\n        data-tone={tone}\n        className={cn(\n          \"group/chip flex h-7 min-w-0 max-w-full items-center rounded-md border text-[12.5px] transition-colors duration-150\",\n          tone === \"invalid\" && \"border-danger/30 bg-danger-soft text-danger\",\n          tone === \"external\" && \"border-warning/30 bg-warning-soft text-fg\",\n          tone === \"valid\" && \"border-line-2 bg-fg/[0.04] text-fg\",\n          \"has-[button[data-chip]:focus-visible]:outline-solid has-[button[data-chip]:focus-visible]:outline-1 has-[button[data-chip]:focus-visible]:outline-offset-1 has-[button[data-chip]:focus-visible]:outline-fg-3\",\n        )}\n      >\n        <button\n          type=\"button\"\n          data-chip\n          data-email={email}\n          data-tone={tone}\n          tabIndex={-1}\n          aria-label={label}\n          aria-keyshortcuts=\"Backspace Delete Enter\"\n          title={tone === \"invalid\" ? \"Not a valid email. Click to edit.\" : tone === \"external\" ? \"Outside your organization\" : undefined}\n          onClick={tone === \"invalid\" ? onEdit : undefined}\n          onDoubleClick={tone !== \"invalid\" ? onEdit : undefined}\n          onKeyDown={onKeyDown}\n          className={cn(\"flex h-full min-w-0 items-center gap-1.5 pl-2 pr-0.5 outline-none\", tone === \"invalid\" ? \"cursor-text\" : \"cursor-default\")}\n        >\n          {tone === \"invalid\" && <Warning size={13} className=\"shrink-0\" />}\n          {tone === \"external\" && <Globe size={13} className=\"shrink-0 text-warning\" />}\n          <span className=\"min-w-0 truncate\">{email}</span>\n        </button>\n        <button\n          type=\"button\"\n          tabIndex={-1}\n          aria-label={`Remove ${email}`}\n          onClick={onRemove}\n          disabled={busy}\n          className={cn(\n            \"relative mr-0.5 grid size-5 shrink-0 place-items-center rounded-[5px] opacity-60 outline-none\",\n            \"transition-[background-color,opacity,scale] duration-150 hover:bg-fg/10 hover:opacity-100 active:scale-90\",\n            \"before:absolute before:-inset-2.5 before:content-[''] pointer-fine:before:hidden\",\n          )}\n        >\n          <X size={12} />\n        </button>\n      </span>\n    </motion.li>\n  );\n}\n\nfunction RolePicker({\n  roles,\n  value,\n  onValueChange,\n  disabled,\n  container,\n}: {\n  roles: InviteRole[];\n  value: string;\n  onValueChange: (v: string) => void;\n  disabled: boolean;\n  container?: HTMLElement | null;\n}) {\n  const items = roles.map((r) => ({ value: r.value, label: r.label }));\n  return (\n    <Select.Root items={items} value={value} onValueChange={(v) => v != null && onValueChange(String(v))} disabled={disabled}>\n      <Select.Trigger\n        aria-label=\"Role\"\n        className={cn(\n          \"group/role relative m-1 ml-0 flex h-7 shrink-0 items-center gap-1 rounded-md pl-2 pr-1.5 text-[12.5px] text-fg-2 outline-none select-none\",\n          \"transition-[background-color,color,scale] duration-150 ease-out-quart hover:bg-fg/[0.06] hover:text-fg active:scale-[0.97] active:duration-75\",\n          \"data-popup-open:bg-fg/[0.08] data-popup-open:text-fg data-disabled:opacity-50\",\n          \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-0 focus-visible:outline-fg-3\",\n        )}\n      >\n        <Select.Value className=\"whitespace-nowrap\" />\n        <Select.Icon className=\"flex text-fg-3 transition-transform duration-200 ease-out-expo group-data-popup-open/role:rotate-180 motion-reduce:transition-none\">\n          <ChevronDown size={14} />\n        </Select.Icon>\n      </Select.Trigger>\n      <Select.Portal container={container}>\n        <Select.Positioner side=\"bottom\" align=\"end\" sideOffset={6} collisionPadding={8} alignItemWithTrigger={false} className=\"z-(--z-popover) outline-none\">\n          <Select.Popup\n            className={cn(\n              \"w-60 max-w-(--available-width) origin-(--transform-origin) rounded-xl border border-line-2 bg-raised p-1 text-fg shadow-pop outline-none\",\n              \"transition-[opacity,scale,translate] duration-160 ease-out-expo\",\n              \"data-starting-style:-translate-y-1 data-starting-style:scale-96 data-starting-style:opacity-0\",\n              \"data-ending-style:scale-98 data-ending-style:opacity-0 data-ending-style:duration-100\",\n              \"motion-reduce:data-starting-style:translate-y-0 motion-reduce:data-starting-style:scale-100\",\n            )}\n          >\n            <Select.List>\n              {roles.map((r) => (\n                <Select.Item\n                  key={r.value}\n                  value={r.value}\n                  className=\"flex min-h-11 cursor-default select-none items-center gap-2 rounded-lg px-2 py-1.5 text-[13px] outline-none transition-colors duration-75 data-highlighted:bg-fg/[0.06]\"\n                >\n                  <span className=\"flex min-w-0 flex-1 flex-col\">\n                    <Select.ItemText className=\"truncate text-fg\">{r.label}</Select.ItemText>\n                    {r.description && <span className=\"truncate text-[12px] leading-4 text-fg-3\">{r.description}</span>}\n                  </span>\n                  <Select.ItemIndicator className=\"grid size-4 shrink-0 place-items-center\">\n                    <Check size={14} />\n                  </Select.ItemIndicator>\n                </Select.Item>\n              ))}\n            </Select.List>\n          </Select.Popup>\n        </Select.Positioner>\n      </Select.Portal>\n    </Select.Root>\n  );\n}\n\nfunction InviteButton({ phase, count, label, disabled, onPress, reduce }: { phase: Phase; count: number; label: string; disabled: boolean; onPress: () => void; reduce: boolean }) {\n  const enter = reduce ? { opacity: 0 } : { opacity: 0, y: 6, filter: \"blur(2px)\" };\n  const leave = reduce ? { opacity: 0 } : { opacity: 0, y: -6, filter: \"blur(2px)\" };\n  return (\n    <button\n      type=\"button\"\n      onClick={onPress}\n      disabled={disabled && phase === \"idle\"}\n      aria-busy={phase === \"sending\" || undefined}\n      className={cn(\n        \"relative inline-flex h-9 shrink-0 select-none items-center justify-center rounded-lg bg-fg px-3 text-[13px] font-medium text-frame shadow-[var(--shadow)] outline-none\",\n        \"transition-[background-color,opacity,scale] duration-150 ease-out-quart hover:bg-fg/90 active:scale-[0.97] active:duration-75\",\n        \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n        \"disabled:opacity-35 disabled:hover:bg-fg aria-busy:cursor-progress\",\n      )}\n    >\n      {/* All states share one cell, so the button holds its width while it works. */}\n      <span className=\"grid place-items-center\">\n        <span aria-hidden className=\"invisible col-start-1 row-start-1 flex items-center gap-1.5\">\n          {label}\n          <span className=\"min-w-[2ch] tabular\">00</span>\n        </span>\n        <span aria-hidden className=\"invisible col-start-1 row-start-1 flex items-center gap-1.5\">\n          <Check size={14} />\n          Sent\n        </span>\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          <motion.span\n            key={phase}\n            className=\"col-start-1 row-start-1 flex items-center gap-1.5\"\n            initial={enter}\n            animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n            exit={leave}\n            transition={reduce ? { duration: 0.12 } : phase === \"sent\" ? spring.pop : { duration: 0.2, ease: ease.out }}\n          >\n            {phase === \"idle\" ? (\n              <>\n                {label}\n                {count > 0 && <NumberFlow value={count} animated={!reduce} className=\"tabular opacity-60\" />}\n              </>\n            ) : phase === \"sending\" ? (\n              <>\n                <Loader size={14} className=\"animate-spin\" />\n                <span className=\"sr-only\">Sending invites</span>\n              </>\n            ) : (\n              <>\n                <svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.6} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden>\n                  <motion.path\n                    d=\"M3.5 8.5 6.5 11.5 12.5 4.5\"\n                    initial={reduce ? false : { pathLength: 0 }}\n                    animate={{ pathLength: 1 }}\n                    transition={{ duration: 0.3, ease: ease.out, delay: 0.05 }}\n                  />\n                </svg>\n                Sent\n              </>\n            )}\n          </motion.span>\n        </AnimatePresence>\n      </span>\n    </button>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/invite-field"
}
