{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "settings-3",
  "title": "Settings 3: team members and roles",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "Team management: a seats meter, an invite form with role, role filters and search, a member list with role selects, pending invitations and a confirm-before-remove dialog. Stacks cleanly on phones.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/alert-dialog.json",
    "https://ui.ballmac.com/r/avatar.json",
    "https://ui.ballmac.com/r/badge.json",
    "https://ui.ballmac.com/r/button.json",
    "https://ui.ballmac.com/r/dropdown-menu.json",
    "https://ui.ballmac.com/r/input.json",
    "https://ui.ballmac.com/r/search-field.json",
    "https://ui.ballmac.com/r/select.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/blocks/settings-3/settings-3.tsx",
      "content": "// Ballmac UI: Settings 3. https://ui.ballmac.com/blocks/settings-3\n\"use client\"\n\nimport * as React from \"react\"\nimport { MailPlus, MoreHorizontal, SearchX, Send, Trash2 } from \"lucide-react\"\n\nimport { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from \"@/components/ballmac/alert-dialog\"\nimport { Avatar, AvatarFallback } from \"@/components/ballmac/avatar\"\nimport { Badge } from \"@/components/ballmac/badge\"\nimport { Button } from \"@/components/ballmac/button\"\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from \"@/components/ballmac/dropdown-menu\"\nimport { Input } from \"@/components/ballmac/input\"\nimport { SearchField } from \"@/components/ballmac/search-field\"\nimport { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from \"@/components/ballmac/select\"\nimport { cn } from \"@/lib/utils\"\n\ntype Settings3Member = {\n  id: string\n  name: string\n  email: string\n  role: string\n  status: \"active\" | \"invited\"\n  /** When they were last active, in words, e.g. \"2 hours ago\". */\n  lastActive?: string\n}\n\ntype Settings3Props = Omit<React.ComponentProps<\"section\">, \"title\"> & {\n  /** Page heading. */\n  title?: string\n  /** One line under the heading. */\n  description?: string\n  /** People in the workspace. */\n  members?: Settings3Member[]\n  /** Roles that can be assigned. The role named \"Owner\" can't be changed or removed. */\n  roles?: string[]\n  /** Seats included in the plan. */\n  seats?: number\n  /** Called with the email and role when an invitation is sent. Throw to show an error. */\n  onInvite?: (email: string, role: string) => void | Promise<void>\n  /** Called when someone's role changes. */\n  onRoleChange?: (id: string, role: string) => void\n  /** Called after a removal is confirmed. */\n  onRemove?: (id: string) => void\n}\n\nconst defaults: Settings3Member[] = [\n  { id: \"1\", name: \"Jordan Lee\", email: \"jordan@acme.com\", role: \"Owner\", status: \"active\", lastActive: \"Active now\" },\n  { id: \"2\", name: \"Maya Kim\", email: \"maya@acme.com\", role: \"Admin\", status: \"active\", lastActive: \"2 hours ago\" },\n  { id: \"3\", name: \"Jonas Ortiz\", email: \"jonas@acme.com\", role: \"Editor\", status: \"active\", lastActive: \"Yesterday\" },\n  { id: \"4\", name: \"Amara Singh\", email: \"amara@acme.com\", role: \"Editor\", status: \"active\", lastActive: \"3 days ago\" },\n  { id: \"5\", name: \"Elena Fischer\", email: \"elena@acme.com\", role: \"Viewer\", status: \"active\", lastActive: \"Last week\" },\n  { id: \"6\", name: \"tomas@acme.com\", email: \"tomas@acme.com\", role: \"Editor\", status: \"invited\" },\n  { id: \"7\", name: \"yuki@acme.com\", email: \"yuki@acme.com\", role: \"Viewer\", status: \"invited\" },\n]\n\nconst tones = [\"bg-chart-1/25\", \"bg-chart-3/25\", \"bg-chart-5/25\", \"bg-chart-2/25\", \"bg-chart-4/25\"]\nconst emailPattern = /^\\S+@\\S+\\.\\S+$/\nconst initials = (name: string) => name.replace(/@.*/, \"\").split(/[\\s.]+/).map((w) => w[0]?.toUpperCase()).join(\"\").slice(0, 2)\n\nfunction Settings3({\n  title = \"Team\",\n  description = \"Invite people and choose what each of them can do.\",\n  members: initialMembers = defaults,\n  roles = [\"Admin\", \"Editor\", \"Viewer\"],\n  seats = 10,\n  onInvite,\n  onRoleChange,\n  onRemove,\n  className,\n  ...props\n}: Settings3Props) {\n  const [members, setMembers] = React.useState(initialMembers)\n  const [query, setQuery] = React.useState(\"\")\n  const [filter, setFilter] = React.useState(\"All\")\n  const [email, setEmail] = React.useState(\"\")\n  const [inviteRole, setInviteRole] = React.useState(roles[roles.length > 1 ? 1 : 0] ?? \"Viewer\")\n  const [error, setError] = React.useState<string>()\n  const [sending, setSending] = React.useState(false)\n  const [notice, setNotice] = React.useState(\"\")\n  const [removing, setRemoving] = React.useState<Settings3Member | null>(null)\n  const emailId = React.useId()\n\n  const used = members.length\n  const full = used >= seats\n  const q = query.trim().toLowerCase()\n  const visible = members.filter(\n    (m) => (filter === \"All\" || m.role === filter || (filter === \"Pending\" && m.status === \"invited\")) && (!q || `${m.name} ${m.email}`.toLowerCase().includes(q))\n  )\n  const filters = [\"All\", ...Array.from(new Set(members.map((m) => m.role))), \"Pending\"]\n\n  async function invite(e: React.FormEvent) {\n    e.preventDefault()\n    const address = email.trim()\n    if (!emailPattern.test(address)) return setError(\"Enter a full email address.\")\n    if (members.some((m) => m.email.toLowerCase() === address.toLowerCase())) return setError(\"That person is already on the team.\")\n    if (full) return setError(`All ${seats} seats are in use. Remove someone or upgrade your plan.`)\n    setError(undefined)\n    setSending(true)\n    try {\n      if (onInvite) await onInvite(address, inviteRole)\n      else await new Promise((r) => setTimeout(r, 600))\n      setMembers((m) => [...m, { id: `new-${m.length + 1}-${address}`, name: address, email: address, role: inviteRole, status: \"invited\" }])\n      setEmail(\"\")\n      setNotice(`Invitation sent to ${address}.`)\n    } catch {\n      setError(\"We couldn’t send that invitation. Please try again.\")\n    } finally {\n      setSending(false)\n    }\n  }\n\n  function changeRole(m: Settings3Member, role: string) {\n    setMembers((all) => all.map((x) => (x.id === m.id ? { ...x, role } : x)))\n    onRoleChange?.(m.id, role)\n    setNotice(`${m.status === \"invited\" ? m.email : m.name} is now ${/^[aeiou]/i.test(role) ? \"an\" : \"a\"} ${role}.`)\n  }\n\n  return (\n    <section data-slot=\"settings-3\" className={cn(\"mx-auto w-full max-w-4xl px-4 py-8 sm:px-6\", className)} {...props}>\n      <div className=\"flex flex-wrap items-end justify-between gap-4\">\n        <div>\n          <h2 className=\"text-2xl font-semibold tracking-[-0.03em]\">{title}</h2>\n          <p className=\"text-muted-foreground mt-1 text-sm\">{description}</p>\n        </div>\n        <div className=\"w-full sm:w-56\">\n          <div className=\"flex items-baseline justify-between text-sm\">\n            <span className=\"font-medium\">Seats</span>\n            <span className=\"text-muted-foreground tabular-nums\">{used} of {seats} used</span>\n          </div>\n          <div className=\"bg-muted mt-2 h-2 overflow-hidden rounded-full\" role=\"img\" aria-label={`${used} of ${seats} seats used`}>\n            <div className={cn(\"h-full rounded-full transition-[width] duration-300 motion-reduce:transition-none\", used / seats >= 0.9 ? \"bg-chart-3\" : \"bg-chart-1\")} style={{ width: `${Math.min(100, (used / seats) * 100)}%` }} />\n          </div>\n        </div>\n      </div>\n\n      <form onSubmit={invite} noValidate className=\"bg-card mt-6 rounded-2xl border p-4 sm:p-5\">\n        <label htmlFor={emailId} className=\"flex items-center gap-2 text-sm font-medium\"><MailPlus className=\"size-4\" aria-hidden=\"true\" />Invite someone</label>\n        <div className=\"mt-3 flex flex-col gap-2 sm:flex-row\">\n          <Input id={emailId} type=\"email\" autoComplete=\"off\" placeholder=\"name@company.com\" value={email} onChange={(e) => { setEmail(e.target.value); setError(undefined) }} aria-invalid={error ? true : undefined} aria-describedby={error ? `${emailId}-error` : undefined} className=\"sm:flex-1\" />\n          <Select value={inviteRole} onValueChange={setInviteRole}>\n            <SelectTrigger aria-label=\"Role for the invitation\" className=\"w-full sm:w-36\"><SelectValue /></SelectTrigger>\n            <SelectContent>{roles.map((r) => <SelectItem key={r} value={r}>{r}</SelectItem>)}</SelectContent>\n          </Select>\n          <Button type=\"submit\" loading={sending}><Send  className=\"rtl:-scale-x-100\"/> Send invite</Button>\n        </div>\n        <p id={`${emailId}-error`} role=\"alert\" className=\"text-destructive mt-2 min-h-5 text-sm\">{error}</p>\n      </form>\n\n      <div className=\"mt-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between\">\n        <div role=\"group\" aria-label=\"Filter by role\" className=\"flex gap-1.5 overflow-x-auto pb-1 [scrollbar-width:none]\">\n          {filters.map((f) => (\n            <button key={f} type=\"button\" aria-pressed={filter === f} onClick={() => setFilter(f)} className={cn(\"focus-visible:ring-ring/50 h-8 shrink-0 rounded-full border px-3.5 text-[13px] font-medium outline-none transition-colors focus-visible:ring-[3px]\", filter === f ? \"bg-foreground text-background border-transparent\" : \"text-muted-foreground hover:text-foreground hover:bg-accent\")}>{f}</button>\n          ))}\n        </div>\n        <SearchField className=\"sm:w-64\" label=\"Search people\" placeholder=\"Search people\" value={query} onValueChange={setQuery} />\n      </div>\n\n      <p role=\"status\" className=\"sr-only\">{notice || `${visible.length} ${visible.length === 1 ? \"person\" : \"people\"} shown.`}</p>\n      {notice && <p aria-hidden=\"true\" className=\"text-muted-foreground mt-3 text-sm\">{notice}</p>}\n\n      <ul className=\"bg-card mt-3 divide-y overflow-hidden rounded-2xl border\">\n        {visible.map((m, i) => {\n          const owner = m.role === \"Owner\"\n          return (\n            <li key={m.id} className=\"grid grid-cols-[1fr_auto] items-center gap-x-3 gap-y-3 px-4 py-4 sm:grid-cols-[1fr_9rem_8rem_auto] sm:px-5\">\n              <div className=\"flex min-w-0 items-center gap-3\">\n                <Avatar><AvatarFallback className={cn(\"text-foreground text-xs font-semibold\", tones[i % tones.length])}>{initials(m.name)}</AvatarFallback></Avatar>\n                <div className=\"min-w-0\">\n                  <p className=\"truncate text-sm font-medium\">{m.status === \"invited\" ? m.email : m.name}</p>\n                  <p className=\"text-muted-foreground truncate text-xs\">{m.status === \"invited\" ? \"Invitation sent\" : m.email}{m.lastActive ? <span className=\"sm:hidden\"> · {m.lastActive}</span> : null}</p>\n                </div>\n              </div>\n              <div className=\"col-start-1 row-start-2 sm:col-start-2 sm:row-start-1\">\n                {owner ? (\n                  <Badge variant=\"outline\">Owner</Badge>\n                ) : (\n                  <Select value={m.role} onValueChange={(r) => changeRole(m, r)}>\n                    <SelectTrigger size=\"sm\" aria-label={`Role for ${m.status === \"invited\" ? m.email : m.name}`} className=\"w-full sm:w-32\"><SelectValue /></SelectTrigger>\n                    <SelectContent>{roles.map((r) => <SelectItem key={r} value={r}>{r}</SelectItem>)}</SelectContent>\n                  </Select>\n                )}\n              </div>\n              <div className=\"hidden sm:block\">\n                {m.status === \"invited\" ? <Badge status=\"warning\">Pending</Badge> : <span className=\"text-muted-foreground text-sm\">{m.lastActive}</span>}\n              </div>\n              <div className=\"col-start-2 row-start-1 sm:col-start-4\">\n                {!owner && (\n                  <DropdownMenu>\n                    <DropdownMenuTrigger aria-label={`Actions for ${m.status === \"invited\" ? m.email : m.name}`} className=\"hover:bg-accent focus-visible:ring-ring/50 flex size-9 items-center justify-center rounded-lg outline-none focus-visible:ring-[3px]\"><MoreHorizontal className=\"size-4\" aria-hidden=\"true\" /></DropdownMenuTrigger>\n                    <DropdownMenuContent align=\"end\">\n                      {m.status === \"invited\" && <DropdownMenuItem onSelect={() => setNotice(`Invitation resent to ${m.email}.`)}><Send  className=\"rtl:-scale-x-100\"/> Resend invite</DropdownMenuItem>}\n                      {m.status === \"invited\" && <DropdownMenuSeparator />}\n                      <DropdownMenuItem destructive onSelect={() => setRemoving(m)}><Trash2 /> {m.status === \"invited\" ? \"Cancel invite\" : \"Remove from team\"}</DropdownMenuItem>\n                    </DropdownMenuContent>\n                  </DropdownMenu>\n                )}\n              </div>\n            </li>\n          )\n        })}\n        {visible.length === 0 && (\n          <li className=\"flex flex-col items-center px-6 py-14 text-center\">\n            <span className=\"bg-muted flex size-12 items-center justify-center rounded-full\"><SearchX className=\"size-5\" aria-hidden=\"true\" /></span>\n            <p className=\"mt-4 font-semibold\">No one matches</p>\n            <p className=\"text-muted-foreground mt-1 text-sm\">Try a different search or role.</p>\n          </li>\n        )}\n      </ul>\n\n      <AlertDialog open={!!removing} onOpenChange={(o) => !o && setRemoving(null)}>\n        <AlertDialogContent>\n          <AlertDialogHeader>\n            <AlertDialogTitle>{removing?.status === \"invited\" ? \"Cancel this invitation?\" : `Remove ${removing?.name}?`}</AlertDialogTitle>\n            <AlertDialogDescription>\n              {removing?.status === \"invited\" ? `${removing.email} won’t be able to join with the link they were sent.` : \"They lose access right away. Their work stays in the workspace.\"}\n            </AlertDialogDescription>\n          </AlertDialogHeader>\n          <AlertDialogFooter>\n            <AlertDialogCancel>Keep</AlertDialogCancel>\n            <AlertDialogAction destructive onClick={() => { if (removing) { setMembers((all) => all.filter((x) => x.id !== removing.id)); onRemove?.(removing.id); setNotice(`${removing.status === \"invited\" ? removing.email : removing.name} was removed.`) } }}>\n              {removing?.status === \"invited\" ? \"Cancel invite\" : \"Remove\"}\n            </AlertDialogAction>\n          </AlertDialogFooter>\n        </AlertDialogContent>\n      </AlertDialog>\n    </section>\n  )\n}\n\nexport { Settings3, type Settings3Props, type Settings3Member }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/settings-3/settings-3.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "settings",
      "team",
      "members",
      "roles",
      "invite",
      "permissions",
      "seats"
    ],
    "version": "1.0.0",
    "updated": "2026-10-01",
    "ai": {
      "summary": "Workspace member management. Pass members=[{ id, name, email, role, status, lastActive? }], roles, seats and onInvite / onRoleChange / onRemove; the Owner can't be changed or removed.",
      "whenToUse": [
        "Workspace and organisation settings",
        "Any product with roles and seats"
      ],
      "whenNotToUse": [
        "A first-run invite step (use onboarding-1)",
        "Sending many invites at once (use invite-members)"
      ],
      "composesWith": [
        "app-shell-1",
        "settings-1",
        "billing-1",
        "invite-1"
      ],
      "a11y": [
        {
          "keys": "Enter in the email field",
          "action": "Sends the invitation; errors are announced and linked to the field"
        },
        {
          "keys": "Role select",
          "action": "Each is named 'Role for <person>'; changes are announced politely"
        },
        {
          "keys": "Actions menu",
          "action": "Opens a menu; removing asks for confirmation in an alert dialog"
        }
      ],
      "customization": [
        "members, roles, seats",
        "onInvite(email, role) async; throw to show an error",
        "onRoleChange(id, role), onRemove(id)"
      ]
    },
    "examples": [
      "settings-3-demo",
      "settings-3-full"
    ],
    "url": "https://ui.ballmac.com/components/settings-3"
  },
  "categories": [
    "blocks",
    "settings"
  ],
  "type": "registry:block"
}