{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "settings-4",
  "title": "Settings 4: integrations directory",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "An integrations page: search, category filters, connected ones first, Connect with progress and the reason when it fails, Manage, Disconnect after a confirmation, coming-soon entries and a request link.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/alert-dialog.json",
    "https://ui.ballmac.com/r/badge.json",
    "https://ui.ballmac.com/r/button.json",
    "https://ui.ballmac.com/r/skeleton.json",
    "https://ui.ballmac.com/r/state-view.json",
    "https://ui.ballmac.com/r/return-focus.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/blocks/settings-4/settings-4.tsx",
      "content": "// Ballmac UI: Settings 4. https://ui.ballmac.com/blocks/settings-4\n\"use client\"\n\nimport * as React from \"react\"\nimport { BarChart3, CalendarDays, CircleAlert, CreditCard, FolderGit2, HardDrive, Loader2, Mail, MessagesSquare, Plug, Search, Sheet, SquareKanban, Users, Video, Webhook } from \"lucide-react\"\n\nimport { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from \"@/components/ballmac/alert-dialog\"\nimport { Badge } from \"@/components/ballmac/badge\"\nimport { Button } from \"@/components/ballmac/button\"\nimport { Skeleton } from \"@/components/ballmac/skeleton\"\nimport { useReturnFocus } from \"@/lib/ballmac/return-focus\"\nimport { frameProps, StateView, type StateProps } from \"@/components/ballmac/state-view\"\nimport { cn } from \"@/lib/utils\"\n\ntype Settings4Integration = {\n  id: string\n  name: string\n  /** What connecting it does, in one sentence. */\n  description: string\n  category: string\n  icon?: React.ReactNode\n  connected?: boolean\n  /** The connected account, shown under the name (\"acme-team\"). */\n  account?: string\n  /** Not available yet: shown with \"Coming soon\" and no Connect button. */\n  comingSoon?: boolean\n}\n\ntype Settings4Props = Omit<React.ComponentProps<\"section\">, \"title\"> &\n  StateProps & {\n    integrations?: Settings4Integration[]\n    title?: string\n    description?: string\n    /** Called by Connect. Resolve with the account name to show it; throw an Error to show its message on the card. Defaults to a short simulated connection. */\n    onConnect?: (id: string) => string | void | Promise<string | void>\n    /** Called after the disconnect is confirmed. */\n    onDisconnect?: (id: string) => void | Promise<void>\n    /** Called by Manage on a connected integration. Without it the button is hidden. */\n    onManage?: (id: string) => void\n    /** Called by \"Request an integration\". Without it the link is hidden. */\n    onRequest?: () => void\n  }\n\nconst sample: Settings4Integration[] = [\n  { id: \"chat\", name: \"Team chat\", description: \"Post updates to a channel and reply to alerts without leaving the conversation.\", category: \"Communication\", icon: <MessagesSquare />, connected: true, account: \"acme-team\" },\n  { id: \"code\", name: \"Code hosting\", description: \"Link pull requests to tasks and close them when the branch is merged.\", category: \"Engineering\", icon: <FolderGit2 />, connected: true, account: \"acme-org\" },\n  { id: \"calendar\", name: \"Calendar\", description: \"Show due dates on your calendar and book reviews from a task.\", category: \"Productivity\", icon: <CalendarDays /> },\n  { id: \"payments\", name: \"Payments\", description: \"Sync customers and invoices so revenue shows up in reports.\", category: \"Finance\", icon: <CreditCard /> },\n  { id: \"issues\", name: \"Issue tracker\", description: \"Turn feedback into issues and keep their status in sync both ways.\", category: \"Engineering\", icon: <SquareKanban /> },\n  { id: \"storage\", name: \"Cloud storage\", description: \"Attach files from your drive and keep them up to date.\", category: \"Productivity\", icon: <HardDrive /> },\n  { id: \"crm\", name: \"CRM\", description: \"See each customer’s plan and open deals next to their requests.\", category: \"Sales\", icon: <Users /> },\n  { id: \"email\", name: \"Email marketing\", description: \"Add new sign-ups to a list and tag them by plan.\", category: \"Sales\", icon: <Mail /> },\n  { id: \"analytics\", name: \"Analytics\", description: \"Send product events to your analytics workspace.\", category: \"Engineering\", icon: <BarChart3 /> },\n  { id: \"video\", name: \"Video calls\", description: \"Start a call from a task and save the recording to it.\", category: \"Communication\", icon: <Video /> },\n  { id: \"sheets\", name: \"Spreadsheets\", description: \"Export any view to a sheet that refreshes every hour.\", category: \"Productivity\", icon: <Sheet />, comingSoon: true },\n  { id: \"webhooks\", name: \"Webhooks\", description: \"Send a signed request to your own endpoint when something changes.\", category: \"Engineering\", icon: <Webhook /> },\n]\n\nfunction GridSkeleton() {\n  return (\n    <div className=\"grid gap-3 sm:grid-cols-2 lg:grid-cols-3\" aria-hidden=\"true\">\n      {Array.from({ length: 6 }, (_, i) => (\n        <div key={i} className=\"grid gap-3 rounded-xl border p-4\">\n          <div className=\"flex items-center gap-3\">\n            <Skeleton className=\"size-10 rounded-lg\" />\n            <Skeleton className=\"h-4 w-1/3\" />\n          </div>\n          <Skeleton className=\"h-3 w-full\" />\n          <Skeleton className=\"h-3 w-4/5\" />\n          <Skeleton className=\"h-8 w-24 rounded-md\" />\n        </div>\n      ))}\n    </div>\n  )\n}\n\n/**\n * The integrations page of a product: search, categories, connected ones first, Connect with progress and the reason when it fails,\n * Manage, Disconnect after a confirmation, coming-soon entries and a way to ask for one that is missing.\n */\nfunction Settings4({\n  integrations = sample,\n  title = \"Integrations\",\n  description = \"Connect the tools your team already uses.\",\n  onConnect,\n  onDisconnect,\n  onManage,\n  onRequest,\n  state = \"ready\",\n  onRetry,\n  emptyState,\n  errorState,\n  className,\n  ...props\n}: Settings4Props) {\n  const id = React.useId()\n  const [list, setList] = React.useState(integrations)\n  React.useEffect(() => setList(integrations), [integrations])\n  const [query, setQuery] = React.useState(\"\")\n  const [category, setCategory] = React.useState(\"All\")\n  const [busy, setBusy] = React.useState<string | null>(null)\n  const [errors, setErrors] = React.useState<Record<string, string>>({})\n  const [confirm, setConfirm] = React.useState<Settings4Integration | null>(null)\n  const returnFocus = useReturnFocus(!!confirm)\n  const [announce, setAnnounce] = React.useState(\"\")\n\n  const categories = [\"All\", ...[...new Set(list.map((i) => i.category))].sort()]\n  const q = query.trim().toLowerCase()\n  const visible = list.filter((i) => (category === \"All\" || i.category === category) && (!q || `${i.name} ${i.description} ${i.category}`.toLowerCase().includes(q)))\n  const connected = visible.filter((i) => i.connected)\n  const available = visible.filter((i) => !i.connected)\n  const connectedCount = list.filter((i) => i.connected).length\n  const ready = state === \"ready\"\n\n  async function connect(i: Settings4Integration) {\n    setBusy(i.id)\n    setErrors(({ [i.id]: _, ...rest }) => rest)\n    try {\n      const account = onConnect ? await onConnect(i.id) : await new Promise<string>((r) => setTimeout(() => r(\"acme\"), 900))\n      setList((l) => l.map((x) => (x.id === i.id ? { ...x, connected: true, account: account || x.account } : x)))\n      setAnnounce(`${i.name} connected`)\n    } catch (err) {\n      setErrors((e) => ({ ...e, [i.id]: err instanceof Error && err.message ? err.message : \"Could not connect. Try again.\" }))\n    } finally {\n      setBusy(null)\n    }\n  }\n\n  async function disconnect(i: Settings4Integration) {\n    setConfirm(null)\n    setBusy(i.id)\n    try {\n      await onDisconnect?.(i.id)\n      setList((l) => l.map((x) => (x.id === i.id ? { ...x, connected: false, account: undefined } : x)))\n      setAnnounce(`${i.name} disconnected`)\n    } finally {\n      setBusy(null)\n    }\n  }\n\n  const card = (i: Settings4Integration) => {\n    const working = busy === i.id\n    const error = errors[i.id]\n    return (\n      <li key={i.id} className={cn(\"bg-card flex flex-col gap-3 rounded-xl border p-4\", i.connected && \"border-chart-2/40\")}>\n        <div className=\"flex items-start gap-3\">\n          <span aria-hidden=\"true\" className=\"bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg border [&_svg]:size-5\">\n            {i.icon ?? <span className=\"text-sm font-semibold\">{i.name.slice(0, 1)}</span>}\n          </span>\n          <div className=\"min-w-0 flex-1\">\n            <h4 className=\"flex flex-wrap items-center gap-x-2 gap-y-1 text-sm font-semibold\">\n              {i.name}\n              {i.connected ? <Badge status=\"success\">Connected</Badge> : i.comingSoon ? <Badge status=\"neutral\" dot={false}>Coming soon</Badge> : null}\n            </h4>\n            <p className=\"text-muted-foreground mt-0.5 truncate text-xs\">{i.connected && i.account ? i.account : i.category}</p>\n          </div>\n        </div>\n        <p className=\"text-muted-foreground flex-1 text-[13px] text-pretty\">{i.description}</p>\n        {error ? (\n          <p role=\"alert\" className=\"text-destructive flex items-start gap-1.5 text-xs\">\n            <CircleAlert aria-hidden=\"true\" className=\"mt-px size-3.5 shrink-0\" />\n            {error}\n          </p>\n        ) : null}\n        <div className=\"flex flex-wrap gap-2\">\n          {i.connected ? (\n            <>\n              {onManage ? (\n                <Button variant=\"outline\" size=\"sm\" onClick={() => onManage(i.id)} aria-label={`Manage ${i.name}`}>\n                  Manage\n                </Button>\n              ) : null}\n              <Button variant=\"ghost\" size=\"sm\" onClick={() => setConfirm(i)} disabled={working} aria-label={`Disconnect ${i.name}`}>\n                {working ? <Loader2 aria-hidden=\"true\" className=\"animate-spin motion-reduce:animate-none\" /> : null}\n                Disconnect\n              </Button>\n            </>\n          ) : i.comingSoon ? null : (\n            <Button variant=\"outline\" size=\"sm\" onClick={() => connect(i)} disabled={working} aria-label={`Connect ${i.name}`}>\n              {working ? <Loader2 aria-hidden=\"true\" className=\"animate-spin motion-reduce:animate-none\" /> : <Plug aria-hidden=\"true\" />}\n              {working ? \"Connecting…\" : \"Connect\"}\n            </Button>\n          )}\n        </div>\n      </li>\n    )\n  }\n\n  return (\n    <section data-slot=\"settings-4\" data-state={state} aria-labelledby={`${id}-title`} className={cn(\"mx-auto w-full max-w-6xl px-4 py-8 sm:px-6\", className)} {...frameProps(props)}>\n      <div className=\"flex flex-wrap items-end gap-4\">\n        <div className=\"min-w-0 flex-1\">\n          <h2 id={`${id}-title`} className=\"text-2xl font-semibold tracking-[-0.03em]\">\n            {title}\n          </h2>\n          <p className=\"text-muted-foreground mt-1 text-sm\">\n            {description}\n            {ready && connectedCount ? ` ${connectedCount} connected.` : \"\"}\n          </p>\n        </div>\n        <div className=\"bg-background focus-within:ring-ring/50 flex h-9 w-full items-center gap-2 rounded-md border px-2.5 focus-within:ring-[3px] sm:w-64\">\n          <Search aria-hidden=\"true\" className=\"text-muted-foreground size-4\" />\n          <input type=\"search\" aria-label=\"Search integrations\" placeholder=\"Search integrations\" value={query} onChange={(e) => setQuery(e.target.value)} disabled={!ready} className=\"placeholder:text-muted-foreground min-w-0 flex-1 bg-transparent text-sm outline-none\" />\n        </div>\n      </div>\n\n      <div role=\"group\" aria-label=\"Category\" className=\"mt-5 flex flex-wrap gap-1.5\">\n        {categories.map((c) => (\n          <button\n            key={c}\n            type=\"button\"\n            aria-pressed={category === c}\n            disabled={!ready}\n            onClick={() => setCategory(c)}\n            className={cn(\n              \"h-8 rounded-full border px-3 text-[13px] outline-none transition-colors duration-150 focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:opacity-50\",\n              category === c ? \"bg-foreground text-background border-foreground\" : \"hover:bg-accent\"\n            )}\n          >\n            {c}\n          </button>\n        ))}\n      </div>\n\n      <StateView\n        state={ready && !list.length ? \"empty\" : state}\n        onRetry={onRetry}\n        label=\"Loading integrations\"\n        loading={<GridSkeleton />}\n        empty={{ title: emptyState?.title ?? \"No integrations yet\", description: emptyState?.description ?? \"Integrations you can connect will be listed here.\", icon: emptyState?.icon ?? <Plug />, action: emptyState?.action }}\n        error={{ title: errorState?.title ?? \"Integrations could not be loaded\", ...errorState }}\n        className=\"mt-6\"\n      >\n        <div className=\"grid gap-8\">\n          {connected.length ? (\n            <div className=\"grid gap-3\">\n              <h3 className=\"text-sm font-semibold\">Connected</h3>\n              <ul className=\"grid gap-3 sm:grid-cols-2 lg:grid-cols-3\">{connected.map(card)}</ul>\n            </div>\n          ) : null}\n          {available.length ? (\n            <div className=\"grid gap-3\">\n              <h3 className=\"text-sm font-semibold\">{connected.length ? \"Available\" : \"All integrations\"}</h3>\n              <ul className=\"grid gap-3 sm:grid-cols-2 lg:grid-cols-3\">{available.map(card)}</ul>\n            </div>\n          ) : null}\n          {!visible.length ? <p className=\"text-muted-foreground py-10 text-center text-sm\">No integrations match “{query || category}”.</p> : null}\n          {onRequest ? (\n            <p className=\"text-muted-foreground text-center text-sm\">\n              Missing a tool you use?{\" \"}\n              <button type=\"button\" onClick={onRequest} className=\"text-foreground rounded-sm font-medium underline underline-offset-4 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50\">\n                Request an integration\n              </button>\n            </p>\n          ) : null}\n        </div>\n      </StateView>\n\n      <AlertDialog open={!!confirm} onOpenChange={(o) => !o && setConfirm(null)}>\n        <AlertDialogContent {...returnFocus}>\n          <AlertDialogHeader>\n            <AlertDialogTitle>Disconnect {confirm?.name}?</AlertDialogTitle>\n            <AlertDialogDescription>Syncing stops straight away. Data already copied stays, and you can connect again at any time.</AlertDialogDescription>\n          </AlertDialogHeader>\n          <AlertDialogFooter>\n            <AlertDialogCancel>Cancel</AlertDialogCancel>\n            <AlertDialogAction onClick={() => confirm && disconnect(confirm)}>Disconnect</AlertDialogAction>\n          </AlertDialogFooter>\n        </AlertDialogContent>\n      </AlertDialog>\n      <p aria-live=\"polite\" className=\"sr-only\">\n        {announce}\n      </p>\n    </section>\n  )\n}\n\nexport { Settings4, type Settings4Props, type Settings4Integration }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/settings-4/settings-4.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "settings",
      "integrations",
      "apps",
      "connect",
      "marketplace",
      "directory",
      "oauth"
    ],
    "version": "1.0.0",
    "updated": "2026-10-17",
    "tests": true,
    "ai": {
      "summary": "An integrations directory. <Settings4 integrations={[{ id, name, description, category, icon, connected, account, comingSoon }]} onConnect={async (id) => accountName} onDisconnect onManage onRequest />. Throw from onConnect to show the reason on the card; categories come from the data.",
      "whenToUse": [
        "The Integrations or Apps page of a SaaS product",
        "Connecting OAuth apps from settings"
      ],
      "whenNotToUse": [
        "A marketing page that shows logos of integrations (use features-3 or logo-cloud-1)",
        "API keys and webhooks for developers (use api-key-field and webhook-card)"
      ],
      "composesWith": [
        "app-shell-1",
        "settings-1",
        "settings-3"
      ],
      "a11y": [
        {
          "keys": "Tab",
          "action": "Category filters are toggle buttons; each card's buttons name their integration"
        },
        {
          "keys": "Disconnect",
          "action": "An alert dialog confirms first; focus returns to the page"
        },
        {
          "keys": "Connect",
          "action": "\"Connected\" or the failure reason is announced"
        }
      ],
      "customization": [
        "title, description",
        "Integration icons are any node: your brand marks or lucide icons",
        "Connect usually opens an OAuth popup: resolve when it closes successfully"
      ]
    },
    "states": [
      {
        "id": "loading",
        "title": "Loading",
        "example": "settings-4-loading",
        "trigger": "state=\"loading\"",
        "note": "Skeleton cards; search and the category filter are disabled."
      },
      {
        "id": "empty",
        "title": "No integrations",
        "example": "settings-4-empty",
        "trigger": "state=\"empty\" (or integrations={[]})",
        "note": "A clear message; emptyState can add an action. A search with no matches says so in place."
      },
      {
        "id": "error",
        "title": "Could not load",
        "example": "settings-4-error",
        "trigger": "state=\"error\"",
        "note": "An alert with Try again (onRetry). A failed Connect shows its reason on that card instead."
      }
    ],
    "examples": [
      "settings-4-demo",
      "settings-4-loading",
      "settings-4-empty",
      "settings-4-error"
    ],
    "url": "https://ui.ballmac.com/components/settings-4"
  },
  "categories": [
    "blocks",
    "settings"
  ],
  "type": "registry:block"
}