{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "member-list",
  "type": "registry:ui",
  "title": "Member List",
  "description": "Team members and invites with rolling roles, confirmed removal and highlighted search.",
  "categories": [
    "settings"
  ],
  "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/member-list.tsx",
      "content": "\"use client\";\nimport { Popover } from \"@base-ui/react/popover\";\nimport { Select } from \"@base-ui/react/select\";\nimport NumberFlow from \"@number-flow/react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useEffect, useId, useRef, useState, useSyncExternalStore } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { Check, ChevronDown, Loader, Mail, Search, X } from \"@/lib/icons\";\nimport { ease, spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\n/* ------------------------------------------------------------------ */\n/* Types                                                               */\n/* ------------------------------------------------------------------ */\n\nexport type Member = {\n  id: string;\n  email: string;\n  /** Missing for invites that haven't been accepted. */\n  name?: string;\n  avatarUrl?: string;\n  role: string;\n  /** Invited but not joined yet. Shown under Pending invites. */\n  pending?: boolean;\n  /** When the invite was sent. Shown as “Invited 2 days ago”. */\n  invitedAt?: Date | string | number;\n};\n\nexport type MemberRole = { value: string; label: string; description?: string };\n\nexport const defaultMemberRoles: MemberRole[] = [\n  { value: \"owner\", label: \"Owner\", description: \"Full access, including billing and deleting the workspace\" },\n  { value: \"admin\", label: \"Admin\", description: \"Manage members, settings and every project\" },\n  { value: \"member\", label: \"Member\", description: \"Create and edit projects they belong to\" },\n  { value: \"guest\", label: \"Guest\", description: \"View and comment on projects shared with them\" },\n];\n\n/* ------------------------------------------------------------------ */\n/* Helpers                                                             */\n/* ------------------------------------------------------------------ */\n\nconst initials = (name: string) =>\n  name\n    .trim()\n    .split(/\\s+/)\n    .slice(0, 2)\n    .map((w) => w[0]?.toUpperCase() ?? \"\")\n    .join(\"\");\n\nconst displayName = (m: Member) => m.name ?? m.email;\n\n// A minute clock shared by every list on the page. The server has no \"now\", so\n// relative times render on the client only and never cause a hydration mismatch.\nlet clockNow = 0;\nconst clockListeners = new Set<() => void>();\nlet clockTimer: number | undefined;\nfunction subscribeClock(cb: () => void) {\n  clockListeners.add(cb);\n  if (clockListeners.size === 1) {\n    clockNow = Date.now();\n    clockTimer = window.setInterval(() => {\n      if (document.hidden) return;\n      clockNow = Date.now();\n      clockListeners.forEach((l) => l());\n    }, 30_000);\n  }\n  return () => {\n    clockListeners.delete(cb);\n    if (!clockListeners.size) window.clearInterval(clockTimer);\n  };\n}\nfunction useNow() {\n  return useSyncExternalStore(\n    subscribeClock,\n    () => clockNow || (clockNow = Date.now()),\n    () => null,\n  );\n}\n\nfunction ago(date: Member[\"invitedAt\"], now: number) {\n  if (date == null) return \"\";\n  const t = new Date(date).getTime();\n  const s = Math.round((t - now) / 1000);\n  const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: \"auto\" });\n  const abs = Math.abs(s);\n  if (abs < 60) return \"just now\";\n  if (abs < 3600) return rtf.format(Math.round(s / 60), \"minute\");\n  if (abs < 86400) return rtf.format(Math.round(s / 3600), \"hour\");\n  if (abs < 86400 * 30) return rtf.format(Math.round(s / 86400), \"day\");\n  return new Intl.DateTimeFormat(undefined, { day: \"numeric\", month: \"short\", year: \"numeric\" }).format(t);\n}\n\n/** Wraps the matched part of a name or email so the eye lands on why the row is here. */\nfunction Highlight({ text, query }: { text: string; query: string }) {\n  const i = query ? text.toLowerCase().indexOf(query.toLowerCase()) : -1;\n  if (i < 0) return <>{text}</>;\n  return (\n    <>\n      {text.slice(0, i)}\n      <mark className=\"rounded-[3px] bg-fg/[0.12] text-inherit\">{text.slice(i, i + query.length)}</mark>\n      {text.slice(i + query.length)}\n    </>\n  );\n}\n\n/* ------------------------------------------------------------------ */\n/* MemberList                                                          */\n/* ------------------------------------------------------------------ */\n\nexport type MemberListProps = Omit<React.ComponentProps<\"div\">, \"defaultValue\" | \"onChange\"> & {\n  members?: Member[];\n  defaultMembers?: Member[];\n  onMembersChange?: (members: Member[]) => void;\n  roles?: MemberRole[];\n  /** The person looking. Their row says “(you)” and can't be changed or removed here. */\n  currentUserId?: string;\n  /** Names the workspace in the remove confirmation. */\n  workspaceName?: string;\n  /** False shows roles as text and hides every action. */\n  canManage?: boolean;\n  /** Save a role change. The row changes at once and reverts if this rejects. */\n  onRoleChange?: (member: Member, role: string) => void | Promise<unknown>;\n  /** Remove a member. The confirmation stays open while it runs and shows the error if it rejects. */\n  onRemove?: (member: Member) => void | Promise<unknown>;\n  /** Send a pending invite again. Shows Resend on invite rows. */\n  onResend?: (member: Member) => void | Promise<unknown>;\n  /** Withdraw a pending invite. Shows Revoke on invite rows. */\n  onRevoke?: (member: Member) => void | Promise<unknown>;\n  /** Actions beside the search field, like an Invite button. */\n  toolbar?: React.ReactNode;\n  searchPlaceholder?: string;\n  /** Portal target for role menus and confirmations. */\n  portalContainer?: HTMLElement | null;\n};\n\nexport function MemberList({\n  members: membersProp,\n  defaultMembers = [],\n  onMembersChange,\n  roles = defaultMemberRoles,\n  currentUserId,\n  workspaceName,\n  canManage = true,\n  onRoleChange,\n  onRemove,\n  onResend,\n  onRevoke,\n  toolbar,\n  searchPlaceholder = \"Search by name or email\",\n  portalContainer,\n  className,\n  ...rest\n}: MemberListProps) {\n  const uid = useId();\n  const reduce = !!useReducedMotion();\n  const [members, setMembers] = useControllableState({ value: membersProp, defaultValue: defaultMembers, onChange: onMembersChange });\n  const [query, setQuery] = useState(\"\");\n  const [errors, setErrors] = useState<Record<string, string>>({});\n  const [announcement, setAnnouncement] = useState(\"\");\n  const [removing, setRemoving] = useState<ReadonlySet<string>>(() => new Set());\n  const rootRef = useRef<HTMLDivElement>(null);\n  const searchRef = useRef<HTMLInputElement>(null);\n  const focusNext = useRef<string | null>(null);\n  // Saves resolve after other edits may have landed, so they read the list as it is now.\n  const latest = useRef(members);\n  useEffect(() => {\n    latest.current = members;\n  });\n\n  // Rows that arrive after the first render (a new invite) grow in and glow once.\n  // Rows that come back when a search is cleared just appear.\n  const ids = members.map((m) => m.id).join(\"|\");\n  const [known, setKnown] = useState(ids);\n  const [fresh, setFresh] = useState<ReadonlySet<string>>(() => new Set());\n  if (ids !== known) {\n    const before = new Set(known.split(\"|\"));\n    const added = members.filter((m) => !before.has(m.id)).map((m) => m.id);\n    setKnown(ids);\n    if (added.length) setFresh(new Set(added));\n  }\n  useEffect(() => {\n    if (!fresh.size) return;\n    const t = window.setTimeout(() => setFresh(new Set()), 1600);\n    return () => window.clearTimeout(t);\n  }, [fresh]);\n\n  const q = query.trim().toLowerCase();\n  const matches = (m: Member) => !q || m.email.toLowerCase().includes(q) || !!m.name?.toLowerCase().includes(q);\n  const joined = members.filter((m) => !m.pending);\n  const invites = members.filter((m) => m.pending);\n  const shownJoined = joined.filter(matches);\n  const shownInvites = invites.filter(matches);\n  const owners = joined.filter((m) => m.role === \"owner\").length;\n  const roleLabel = (v: string) => roles.find((r) => r.value === v)?.label ?? v;\n\n  const setError = (id: string, message: string | null) =>\n    setErrors((prev) => {\n      const next = { ...prev };\n      if (message) next[id] = message;\n      else delete next[id];\n      return next;\n    });\n\n  const changeRole = async (m: Member, role: string) => {\n    const previous = m.role;\n    setError(m.id, null);\n    // Optimistic: the label rolls to the new role on this frame.\n    setMembers(members.map((x) => (x.id === m.id ? { ...x, role } : x)));\n    setAnnouncement(`${displayName(m)} is now ${roleLabel(role)}`);\n    try {\n      await onRoleChange?.(m, role);\n    } catch {\n      setMembers(latest.current.map((x) => (x.id === m.id ? { ...x, role: previous } : x)));\n      setError(m.id, `Couldn’t change the role. It’s still ${roleLabel(previous)}.`);\n      setAnnouncement(`Couldn’t change ${displayName(m)}’s role`);\n    }\n  };\n\n  // Where focus lands once a row is gone: the next row, else the previous, else the search.\n  const neighbour = (m: Member) => {\n    const list = m.pending ? shownInvites : shownJoined;\n    const i = list.findIndex((x) => x.id === m.id);\n    return list[i + 1]?.id ?? list[i - 1]?.id ?? null;\n  };\n\n  const drop = (m: Member) => {\n    focusNext.current = neighbour(m) ?? \"search\";\n    setRemoving((s) => new Set(s).add(m.id));\n    setMembers(latest.current.filter((x) => x.id !== m.id));\n  };\n\n  const remove = async (m: Member) => {\n    await onRemove?.(m);\n    drop(m);\n    setAnnouncement(`Removed ${displayName(m)}`);\n  };\n\n  const revoke = async (m: Member) => {\n    setError(m.id, null);\n    try {\n      await onRevoke?.(m);\n      drop(m);\n      setAnnouncement(`Revoked the invite to ${m.email}`);\n    } catch {\n      setError(m.id, \"Couldn’t revoke the invite. Try again.\");\n    }\n  };\n\n  const onExitComplete = () => {\n    const target = focusNext.current;\n    focusNext.current = null;\n    if (!target) return;\n    const root = rootRef.current;\n    const el =\n      target === \"search\"\n        ? searchRef.current\n        : root?.querySelector<HTMLElement>(`[data-member=\"${CSS.escape(target)}\"] :is(button, [role=combobox]):not([disabled])`);\n    (el ?? searchRef.current)?.focus({ preventScroll: true });\n  };\n\n  const empty = q && !shownJoined.length && !shownInvites.length;\n\n  const renderRows = (list: Member[]) => (\n    <AnimatePresence initial={false} custom={removing} onExitComplete={onExitComplete}>\n      {list.map((m) => (\n        <motion.li\n          key={m.id}\n          data-member={m.id}\n          custom={removing}\n          variants={{\n            // Only new rows grow in; filtered rows and first paint are already there.\n            hidden: fresh.has(m.id) && !reduce ? { opacity: 0, height: 0 } : { opacity: 1, height: \"auto\" },\n            shown: { opacity: 1, height: \"auto\" },\n            // A removed row folds shut and the list closes the gap; a filtered row just goes.\n            gone: (r: ReadonlySet<string>) =>\n              r.has(m.id)\n                ? reduce\n                  ? { opacity: 0, transition: { duration: 0.12 } }\n                  : { opacity: 0, height: 0, transition: { height: { duration: 0.24, ease: ease.inOut, delay: 0.04 }, opacity: { duration: 0.12 } } }\n                : { opacity: 0, transition: { duration: 0 } },\n          }}\n          initial=\"hidden\"\n          animate=\"shown\"\n          exit=\"gone\"\n          transition={{ height: { duration: 0.26, ease: ease.out }, opacity: { duration: 0.2, delay: 0.06 } }}\n          className=\"overflow-hidden\"\n        >\n          <Row\n            member={m}\n            you={m.id === currentUserId}\n            query={q}\n            fresh={fresh.has(m.id)}\n            locked={!canManage || m.id === currentUserId || (m.role === \"owner\" && owners <= 1 && !m.pending)}\n            canManage={canManage}\n            roles={roles}\n            roleLabel={roleLabel(m.role)}\n            error={errors[m.id]}\n            workspaceName={workspaceName}\n            onRoleChange={(role) => void changeRole(m, role)}\n            onRemove={() => remove(m)}\n            onResend={onResend && canManage ? () => onResend(m) : undefined}\n            onRevoke={onRevoke && canManage ? () => void revoke(m) : undefined}\n            reduce={reduce}\n            container={portalContainer}\n          />\n        </motion.li>\n      ))}\n    </AnimatePresence>\n  );\n\n  return (\n    <div ref={rootRef} className={cn(\"@container flex w-full min-w-0 flex-col gap-3\", className)} {...rest}>\n      <div className=\"flex items-center gap-2\">\n        <label\n          className={cn(\n            \"group/search relative flex h-8 min-w-0 flex-1 items-center gap-2 rounded-lg border border-line-2 bg-raised pl-2.5 pr-1 shadow-[var(--shadow)]\",\n            \"transition-[border-color,box-shadow] duration-150 focus-within:border-fg-4 focus-within:ring-3 focus-within:ring-fg/10\",\n          )}\n        >\n          <Search size={14} className=\"shrink-0 text-fg-3 transition-colors duration-150 group-focus-within/search:text-fg-2\" />\n          <span className=\"sr-only\">Search members</span>\n          <input\n            ref={searchRef}\n            type=\"search\"\n            value={query}\n            onChange={(e) => setQuery(e.target.value)}\n            onKeyDown={(e) => {\n              if (e.key === \"Escape\" && query) {\n                e.preventDefault();\n                setQuery(\"\");\n              }\n            }}\n            placeholder={searchPlaceholder}\n            autoComplete=\"off\"\n            spellCheck={false}\n            enterKeyHint=\"search\"\n            aria-controls={`${uid}-lists`}\n            className=\"h-full min-w-0 flex-1 bg-transparent text-base text-fg outline-none placeholder:text-fg-4 sm:text-[13px] [&::-webkit-search-cancel-button]:appearance-none\"\n          />\n          <AnimatePresence initial={false}>\n            {query && (\n              <motion.button\n                type=\"button\"\n                aria-label=\"Clear search\"\n                onClick={() => {\n                  setQuery(\"\");\n                  searchRef.current?.focus();\n                }}\n                initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}\n                animate={{ opacity: 1, scale: 1 }}\n                exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6, transition: { duration: 0.1 } }}\n                transition={reduce ? { duration: 0.1 } : spring.pop}\n                className={cn(\n                  \"relative grid size-6 shrink-0 place-items-center rounded-md text-fg-3 outline-none transition-colors duration-150 hover:bg-hover hover:text-fg active:scale-[0.92]\",\n                  \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-fg-3\",\n                  \"before:absolute before:-inset-2.5 before:content-[''] pointer-fine:before:hidden\",\n                )}\n              >\n                <X size={14} />\n              </motion.button>\n            )}\n          </AnimatePresence>\n        </label>\n        {toolbar}\n      </div>\n\n      <div id={`${uid}-lists`} className=\"flex min-w-0 flex-col gap-4\">\n        {empty ? (\n          <div className=\"flex flex-col items-center gap-2 rounded-xl border border-dashed border-line-2 px-4 py-8 text-center\">\n            <p className=\"text-[13px] text-fg-2\">\n              No one matches <span className=\"text-fg\">“{query.trim()}”</span>\n            </p>\n            <button\n              type=\"button\"\n              onClick={() => {\n                setQuery(\"\");\n                searchRef.current?.focus();\n              }}\n              className={cn(\n                \"h-7 rounded-md border border-line-2 bg-raised px-2.5 text-[12.5px] font-medium text-fg shadow-[var(--shadow)] outline-none\",\n                \"transition-[background-color,border-color,scale] duration-150 hover:border-fg-4 hover:bg-hover 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              )}\n            >\n              Clear search\n            </button>\n          </div>\n        ) : (\n          <>\n            {(shownJoined.length > 0 || !q) && (\n              <section aria-labelledby={`${uid}-members`} className=\"flex min-w-0 flex-col\">\n                <SectionLabel id={`${uid}-members`} count={shownJoined.length} reduce={reduce}>\n                  Members\n                </SectionLabel>\n                {joined.length === 0 ? (\n                  <p className=\"py-3 text-[12.5px] text-fg-3\">No one has joined yet.</p>\n                ) : (\n                  <ul aria-labelledby={`${uid}-members`} className=\"-mx-2 flex flex-col\">\n                    {renderRows(shownJoined)}\n                  </ul>\n                )}\n              </section>\n            )}\n            {shownInvites.length > 0 && (\n              <section aria-labelledby={`${uid}-invites`} className=\"flex min-w-0 flex-col\">\n                <SectionLabel id={`${uid}-invites`} count={shownInvites.length} reduce={reduce}>\n                  Pending invites\n                </SectionLabel>\n                <ul aria-labelledby={`${uid}-invites`} className=\"-mx-2 flex flex-col\">\n                  {renderRows(shownInvites)}\n                </ul>\n              </section>\n            )}\n          </>\n        )}\n      </div>\n\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {q ? `${shownJoined.length + shownInvites.length} ${shownJoined.length + shownInvites.length === 1 ? \"person\" : \"people\"} found. ` : \"\"}\n        {announcement}\n      </span>\n    </div>\n  );\n}\n\nfunction SectionLabel({ id, count, reduce, children }: { id: string; count: number; reduce: boolean; children: React.ReactNode }) {\n  return (\n    <h3 id={id} className=\"flex items-center gap-1.5 border-b border-line pb-1.5 font-mono text-2xs uppercase tracking-[0.08em] text-fg-3\">\n      {children}\n      <NumberFlow value={count} animated={!reduce} className=\"tabular text-fg-4\" />\n    </h3>\n  );\n}\n\n/* ------------------------------------------------------------------ */\n/* Row                                                                 */\n/* ------------------------------------------------------------------ */\n\ntype RowProps = {\n  member: Member;\n  you: boolean;\n  query: string;\n  fresh: boolean;\n  locked: boolean;\n  canManage: boolean;\n  roles: MemberRole[];\n  roleLabel: string;\n  error?: string;\n  workspaceName?: string;\n  onRoleChange: (role: string) => void;\n  onRemove: () => Promise<void>;\n  onResend?: () => void | Promise<unknown>;\n  onRevoke?: () => void;\n  reduce: boolean;\n  container?: HTMLElement | null;\n};\n\nfunction Row({ member: m, you, query, fresh, locked, canManage, roles, roleLabel, error, workspaceName, onRoleChange, onRemove, onResend, onRevoke, reduce, container }: RowProps) {\n  const name = displayName(m);\n  const now = useNow();\n  const errorId = useId();\n\n  return (\n    <div\n      data-fresh={fresh || undefined}\n      className={cn(\n        \"group/row relative flex min-h-13 min-w-0 items-center gap-3 px-2 py-2\",\n        // A just-added row starts lit and cools to the page over a second.\n        \"before:pointer-events-none before:absolute before:inset-0 before:rounded-lg before:bg-fg/[0.05] before:opacity-0 before:transition-opacity before:duration-1000\",\n        \"data-fresh:before:opacity-100 data-fresh:before:duration-0\",\n      )}\n    >\n      <Avatar member={m} />\n\n      <div className=\"flex min-w-0 flex-1 flex-col\">\n        <p className=\"flex min-w-0 items-baseline gap-1 text-[13px] leading-5 text-fg\">\n          <span className={cn(\"min-w-0 font-medium [overflow-wrap:anywhere]\", m.name && \"truncate @max-[26rem]:whitespace-normal\")}>\n            <Highlight text={name} query={query} />\n          </span>\n          {you && <span className=\"shrink-0 text-fg-3\">(you)</span>}\n        </p>\n        <p className=\"flex min-w-0 flex-wrap items-center gap-x-1.5 text-[12px] leading-4 text-fg-3\">\n          {m.pending ? (\n            <>\n              <span className=\"shrink-0\" suppressHydrationWarning>\n                {m.invitedAt != null && now != null ? `Invited ${ago(m.invitedAt, now)}` : \"Invite sent\"}\n              </span>\n              {/* In a narrow list the actions drop to their own line instead of leaving a dot dangling. */}\n              <span className=\"contents @max-[26rem]:flex @max-[26rem]:basis-full @max-[26rem]:items-center @max-[26rem]:gap-x-1.5\">\n                {onRevoke && (\n                  <>\n                    <Dot className=\"@max-[26rem]:hidden\" />\n                    <TextButton onClick={onRevoke} aria-label={`Revoke the invite to ${m.email}`}>\n                      Revoke\n                    </TextButton>\n                  </>\n                )}\n                {/* Last on the line, so the width it reserves for its longest label never opens a gap. */}\n                {onResend && (\n                  <>\n                    <Dot className={onRevoke ? undefined : \"@max-[26rem]:hidden\"} />\n                    <ResendButton onResend={onResend} reduce={reduce} email={m.email} />\n                  </>\n                )}\n              </span>\n            </>\n          ) : (\n            m.name && (\n              <span className=\"min-w-0 truncate\">\n                <Highlight text={m.email} query={query} />\n              </span>\n            )\n          )}\n        </p>\n        <AnimatePresence initial={false}>\n          {error && (\n            <motion.p\n              id={errorId}\n              role=\"alert\"\n              initial={reduce ? { opacity: 0 } : { opacity: 0, height: 0 }}\n              animate={{ opacity: 1, height: \"auto\" }}\n              exit={reduce ? { opacity: 0 } : { opacity: 0, height: 0, transition: { duration: 0.14 } }}\n              transition={{ duration: 0.2, ease: ease.out }}\n              className=\"overflow-hidden text-[12px] leading-4 text-danger\"\n            >\n              <span className=\"block pt-1\">{error}</span>\n            </motion.p>\n          )}\n        </AnimatePresence>\n      </div>\n\n      <div className=\"flex shrink-0 items-center gap-0.5\">\n        {locked ? (\n          // Padded to end where a trigger's text ends, so every role sits in one column.\n          <span className=\"pl-2 pr-6 text-[12.5px] text-fg-3\">{roleLabel}</span>\n        ) : (\n          <RoleSelect\n            name={name}\n            value={m.role}\n            label={roleLabel}\n            roles={roles}\n            onChange={onRoleChange}\n            describedBy={error ? errorId : undefined}\n            reduce={reduce}\n            container={container}\n          />\n        )}\n        {canManage && !m.pending && (\n          // Holds its place when there's nothing to remove, so role labels stay in one column.\n          <span className=\"grid size-7 place-items-center\">\n            {!locked && <RemoveButton name={name} workspaceName={workspaceName} onRemove={onRemove} container={container} />}\n          </span>\n        )}\n        {canManage && m.pending && <span aria-hidden className=\"size-7\" />}\n      </div>\n    </div>\n  );\n}\n\nfunction Avatar({ member: m }: { member: Member }) {\n  const [failed, setFailed] = useState(false);\n  const showImage = m.avatarUrl && !failed;\n  return (\n    <span\n      aria-hidden\n      className={cn(\n        \"relative grid size-8 shrink-0 select-none place-items-center overflow-hidden rounded-full text-[11.5px] font-medium\",\n        m.pending ? \"border border-dashed border-line-2 text-fg-3\" : \"bg-hover text-fg-2 shadow-[inset_0_0_0_1px_var(--line-2)]\",\n      )}\n    >\n      {showImage ? (\n        // eslint-disable-next-line @next/next/no-img-element\n        <img src={m.avatarUrl} alt=\"\" width={32} height={32} onError={() => setFailed(true)} className=\"size-full object-cover\" />\n      ) : m.pending || !m.name ? (\n        <Mail size={14} />\n      ) : (\n        initials(m.name)\n      )}\n    </span>\n  );\n}\n\nconst Dot = ({ className }: { className?: string }) => (\n  <span aria-hidden className={cn(\"text-fg-4\", className)}>\n    ·\n  </span>\n);\n\nfunction TextButton({ className, ...props }: React.ComponentProps<\"button\">) {\n  return (\n    <button\n      type=\"button\"\n      className={cn(\n        \"relative inline-grid rounded-sm text-fg-2 outline-none transition-colors duration-150 hover:text-fg\",\n        \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3\",\n        \"disabled:text-fg-3\",\n        \"before:absolute before:-inset-x-1.5 before:-inset-y-3.5 before:content-[''] pointer-fine:before:hidden\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\n/** Resend → Sending… → Sent, in one fixed-width cell. Sent holds for a while so nobody sends three. */\nfunction ResendButton({ onResend, reduce, email }: { onResend: () => void | Promise<unknown>; reduce: boolean; email: string }) {\n  const [state, setState] = useState<\"idle\" | \"sending\" | \"sent\" | \"failed\">(\"idle\");\n  useEffect(() => {\n    if (state !== \"sent\" && state !== \"failed\") return;\n    const t = window.setTimeout(() => setState(\"idle\"), state === \"sent\" ? 30_000 : 4000);\n    return () => window.clearTimeout(t);\n  }, [state]);\n\n  const send = async () => {\n    if (state === \"sending\" || state === \"sent\") return;\n    setState(\"sending\");\n    try {\n      await onResend();\n      setState(\"sent\");\n    } catch {\n      setState(\"failed\");\n    }\n  };\n\n  const labels = { idle: \"Resend\", sending: \"Sending…\", sent: \"Sent\", failed: \"Couldn’t send\" };\n  return (\n    <>\n      <TextButton\n        onClick={() => void send()}\n        disabled={state === \"sending\" || state === \"sent\"}\n        aria-label={`${labels[state]}: invite to ${email}`}\n        className={cn(state === \"failed\" && \"text-danger hover:text-danger\")}\n      >\n        {Object.values(labels).map((l) => (\n          <span key={l} aria-hidden className=\"invisible col-start-1 row-start-1 whitespace-nowrap\">\n            {l}\n          </span>\n        ))}\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          <motion.span\n            key={state}\n            initial={reduce ? { opacity: 0 } : { opacity: 0, y: 5, filter: \"blur(2px)\" }}\n            animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n            exit={reduce ? { opacity: 0 } : { opacity: 0, y: -5, filter: \"blur(2px)\", transition: { duration: 0.12 } }}\n            transition={{ duration: 0.2, ease: ease.out }}\n            className=\"col-start-1 row-start-1 flex items-center gap-1 whitespace-nowrap text-left\"\n          >\n            {state === \"sent\" && <DrawnCheck reduce={reduce} />}\n            {labels[state]}\n          </motion.span>\n        </AnimatePresence>\n      </TextButton>\n    </>\n  );\n}\n\nfunction DrawnCheck({ reduce }: { reduce: boolean }) {\n  return (\n    <svg width={12} height={12} viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden className=\"shrink-0 text-success\">\n      <motion.path\n        d=\"M3.5 8.5 6.5 11.5 12.5 4.5\"\n        stroke=\"currentColor\"\n        strokeWidth={1.8}\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        initial={reduce ? false : { pathLength: 0 }}\n        animate={{ pathLength: 1 }}\n        transition={{ duration: 0.3, ease: ease.out, delay: 0.06 }}\n      />\n    </svg>\n  );\n}\n\n/* ------------------------------------------------------------------ */\n/* Role select                                                         */\n/* ------------------------------------------------------------------ */\n\nconst popupSurface =\n  \"origin-(--transform-origin) rounded-xl border border-line-2 bg-raised text-fg shadow-pop outline-none transition-[opacity,scale,translate] duration-160 ease-out-expo data-starting-style:scale-96 data-starting-style:opacity-0 data-ending-style:scale-98 data-ending-style:opacity-0 data-ending-style:duration-100 data-instant:transition-none motion-reduce:data-starting-style:scale-100\";\n\nfunction RoleSelect({\n  name,\n  value,\n  label,\n  roles,\n  onChange,\n  describedBy,\n  reduce,\n  container,\n}: {\n  name: string;\n  value: string;\n  label: string;\n  roles: MemberRole[];\n  onChange: (role: string) => void;\n  describedBy?: string;\n  reduce: boolean;\n  container?: HTMLElement | null;\n}) {\n  return (\n    <Select.Root items={roles} value={value} onValueChange={(v) => v != null && v !== value && onChange(String(v))}>\n      <Select.Trigger\n        aria-label={`Role for ${name}: ${label}`}\n        aria-describedby={describedBy}\n        className={cn(\n          \"group/role relative flex h-7 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\",\n          \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-0 focus-visible:outline-fg-3\",\n          \"before:absolute before:-inset-y-2 before:inset-x-0 before:content-[''] pointer-fine:before:hidden\",\n        )}\n      >\n        {/* Every role label shares one cell, so the trigger never changes width as the role rolls. */}\n        <span className=\"grid overflow-hidden text-right\">\n          {roles.map((r) => (\n            <span key={r.value} aria-hidden className=\"invisible col-start-1 row-start-1 whitespace-nowrap\">\n              {r.label}\n            </span>\n          ))}\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            <motion.span\n              key={label}\n              initial={reduce ? { opacity: 0 } : { opacity: 0, y: 8, filter: \"blur(2px)\" }}\n              animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n              exit={reduce ? { opacity: 0 } : { opacity: 0, y: -8, filter: \"blur(2px)\" }}\n              transition={{ duration: 0.2, ease: ease.out }}\n              className=\"col-start-1 row-start-1 whitespace-nowrap\"\n            >\n              {label}\n            </motion.span>\n          </AnimatePresence>\n        </span>\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 className={cn(\"w-72 max-w-(--available-width) p-1\", popupSurface, \"data-starting-style:-translate-y-1\")}>\n            <Select.List>\n              {roles.map((r) => (\n                <Select.Item\n                  key={r.value}\n                  value={r.value}\n                  label={r.label}\n                  className=\"flex min-h-11 cursor-default select-none items-start gap-2.5 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=\"text-fg\">{r.label}</Select.ItemText>\n                    {r.description && <span className=\"text-[12px] leading-4 text-pretty text-fg-3\">{r.description}</span>}\n                  </span>\n                  <Select.ItemIndicator className=\"grid h-5 w-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\n/* ------------------------------------------------------------------ */\n/* Remove with confirm                                                 */\n/* ------------------------------------------------------------------ */\n\nfunction RemoveButton({\n  name,\n  workspaceName,\n  onRemove,\n  container,\n}: {\n  name: string;\n  workspaceName?: string;\n  onRemove: () => Promise<void>;\n  container?: HTMLElement | null;\n}) {\n  const [open, setOpen] = useState(false);\n  const [busy, setBusy] = useState(false);\n  const [error, setError] = useState<string | null>(null);\n  const cancelRef = useRef<HTMLButtonElement>(null);\n  const removed = useRef(false);\n  const titleId = useId();\n\n  const confirm = async () => {\n    setBusy(true);\n    setError(null);\n    try {\n      await onRemove();\n      // The row folds away and the list moves focus to its neighbour, so don't hand focus back to this trigger.\n      removed.current = true;\n      setOpen(false);\n    } catch {\n      setError(`Couldn’t remove ${name}. Try again.`);\n      setBusy(false);\n    }\n  };\n\n  return (\n    <Popover.Root\n      open={open}\n      onOpenChange={(next) => {\n        if (busy && !removed.current) return;\n        if (next) setError(null);\n        setOpen(next);\n      }}\n    >\n      <Popover.Trigger\n        aria-label={`Remove ${name}`}\n        className={cn(\n          \"relative grid size-7 place-items-center rounded-md text-fg-3 outline-none\",\n          \"transition-[background-color,color,opacity,scale] duration-150 hover:bg-danger-soft hover:text-danger active:scale-[0.92] active:duration-75\",\n          \"data-popup-open:bg-danger-soft data-popup-open:text-danger\",\n          \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-0 focus-visible:outline-fg-3\",\n          // Quiet until the row is hovered or focused on a mouse; always there on touch.\n          \"pointer-fine:opacity-0 pointer-fine:group-hover/row:opacity-100 pointer-fine:focus-visible:opacity-100 pointer-fine:data-popup-open:opacity-100\",\n          \"before:absolute before:-inset-2 before:content-[''] pointer-fine:before:hidden\",\n        )}\n      >\n        <X size={14} />\n      </Popover.Trigger>\n      <Popover.Portal container={container}>\n        <Popover.Positioner side=\"bottom\" align=\"end\" sideOffset={6} collisionPadding={8} className=\"z-(--z-popover)\">\n          <Popover.Popup\n            initialFocus={cancelRef}\n            finalFocus={() => !removed.current}\n            aria-labelledby={titleId}\n            className={cn(\"flex w-72 max-w-(--available-width) flex-col gap-3 p-3.5\", popupSurface, \"data-starting-style:-translate-y-1 data-[side=top]:data-starting-style:translate-y-1\")}\n          >\n            <div className=\"flex flex-col gap-1\">\n              <Popover.Title id={titleId} className=\"text-[13px] font-medium leading-5 text-fg\">\n                Remove {name}?\n              </Popover.Title>\n              <Popover.Description className=\"text-[12.5px] leading-[18px] text-pretty text-fg-3\">\n                They lose access to {workspaceName ?? \"this workspace\"} right away. You can invite them again later.\n              </Popover.Description>\n            </div>\n            {error && (\n              <p role=\"alert\" className=\"rounded-md bg-danger-soft px-2.5 py-1.5 text-[12px] leading-4 text-danger\">\n                {error}\n              </p>\n            )}\n            <div className=\"flex justify-end gap-2\">\n              <Popover.Close\n                ref={cancelRef}\n                disabled={busy}\n                className={cn(\n                  \"h-7 rounded-md border border-line-2 bg-raised px-2.5 text-[12.5px] font-medium text-fg outline-none\",\n                  \"transition-[background-color,border-color,scale] duration-150 hover:border-fg-4 hover:bg-hover active:scale-[0.97] active:duration-75 disabled:opacity-50\",\n                  \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n                )}\n              >\n                Cancel\n              </Popover.Close>\n              <button\n                type=\"button\"\n                onClick={() => void confirm()}\n                aria-busy={busy || undefined}\n                className={cn(\n                  \"relative grid h-7 place-items-center rounded-md bg-danger px-2.5 text-[12.5px] font-medium text-frame outline-none\",\n                  \"transition-[background-color,scale] duration-150 hover:bg-danger/90 active:scale-[0.97] active:duration-75 aria-busy:pointer-events-none\",\n                  \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-danger\",\n                )}\n              >\n                {/* The spinner overlays the label so the button keeps its width. */}\n                <span className={cn(\"col-start-1 row-start-1 transition-opacity duration-150\", busy && \"opacity-0\")}>Remove</span>\n                {busy && (\n                  <span className=\"col-start-1 row-start-1 grid place-items-center\">\n                    <Loader size={14} className=\"animate-spin\" />\n                    <span className=\"sr-only\">Removing</span>\n                  </span>\n                )}\n              </button>\n            </div>\n          </Popover.Popup>\n        </Popover.Positioner>\n      </Popover.Portal>\n    </Popover.Root>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/member-list"
}
