{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "integrations-1",
  "title": "Integrations 1: directory",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A public integrations directory: search that highlights matches, category filters with counts, cards showing what is connected, and a side sheet with what it does and a Connect button that handles waiting and failure.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/badge.json",
    "https://ui.ballmac.com/r/button.json",
    "https://ui.ballmac.com/r/cover-art.json",
    "https://ui.ballmac.com/r/match-highlight.json",
    "https://ui.ballmac.com/r/sheet.json",
    "https://ui.ballmac.com/r/skeleton.json",
    "https://ui.ballmac.com/r/state-view.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/blocks/integrations-1/integrations-1.tsx",
      "content": "// Ballmac UI: Integrations 1. https://ui.ballmac.com/blocks/integrations-1\n\"use client\"\n\nimport * as React from \"react\"\nimport { ArrowUpRight, Check, Loader2, Plug, Search, X } from \"lucide-react\"\n\nimport { Badge } from \"@/components/ballmac/badge\"\nimport { Button, buttonVariants } from \"@/components/ballmac/button\"\nimport { CoverArt } from \"@/components/ballmac/cover-art\"\nimport { MatchHighlight, matchesAll, queryTokens } from \"@/components/ballmac/match-highlight\"\nimport { Sheet, SheetBody, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from \"@/components/ballmac/sheet\"\nimport { Skeleton } from \"@/components/ballmac/skeleton\"\nimport { frameProps, StateView, type StateProps } from \"@/components/ballmac/state-view\"\nimport { cn } from \"@/lib/utils\"\n\ntype Integrations1Item = {\n  id: string\n  name: string\n  category: string\n  /** One line, shown on the card. */\n  summary: string\n  /** A paragraph, shown in the details. */\n  description?: string\n  /** What it does, as short phrases. */\n  features?: string[]\n  badge?: \"Popular\" | \"New\" | \"Beta\"\n  connected?: boolean\n  docsHref?: string\n}\n\ntype Integrations1Props = Omit<React.ComponentProps<\"section\">, \"title\"> &\n  StateProps & {\n    integrations?: Integrations1Item[]\n    title?: string\n    description?: string\n    /** Called when someone connects. Return a promise; reject to show its message. */\n    onConnect?: (item: Integrations1Item) => void | Promise<void>\n    onDisconnect?: (item: Integrations1Item) => void | Promise<void>\n    /** The integration whose details are open at first. */\n    defaultOpenId?: string\n  }\n\nconst sample: Integrations1Item[] = [\n  { id: \"slack\", name: \"Slack\", category: \"Communication\", summary: \"Post updates and approve requests from a channel.\", badge: \"Popular\", connected: true, description: \"Send the right update to the right channel, and let people approve or reject from the message itself.\", features: [\"Channel notifications\", \"Approve from Slack\", \"Daily digest\"], docsHref: \"#\" },\n  { id: \"github\", name: \"GitHub\", category: \"Developer\", summary: \"Link pull requests and issues to your work.\", badge: \"Popular\", description: \"Every pull request shows up next to the task it belongs to, and merging one can finish the task.\", features: [\"Pull request links\", \"Close on merge\", \"Branch names from tasks\"], docsHref: \"#\" },\n  { id: \"gcal\", name: \"Google Calendar\", category: \"Productivity\", summary: \"See due dates and meetings on one calendar.\", connected: true, description: \"Due dates appear as events and meetings block time, so plans match the week you really have.\", features: [\"Two-way sync\", \"Working hours\", \"Meeting-aware schedules\"] },\n  { id: \"notion\", name: \"Notion\", category: \"Productivity\", summary: \"Embed pages and keep docs next to tasks.\", description: \"Pages stay where your team writes them and appear where the work happens.\", features: [\"Embed pages\", \"Search across both\"] },\n  { id: \"stripe\", name: \"Stripe\", category: \"Finance\", summary: \"Bill by the project and reconcile payments.\", badge: \"New\", description: \"Turn tracked work into invoices and see when they are paid, without leaving the project.\", features: [\"Invoices from time\", \"Payment status\", \"Refund notes\"] },\n  { id: \"zapier\", name: \"Zapier\", category: \"Automation\", summary: \"Connect to thousands of other apps.\", description: \"Start from any event and send it to any of the apps Zapier supports.\", features: [\"Triggers and actions\", \"Multi-step zaps\"] },\n  { id: \"figma\", name: \"Figma\", category: \"Design\", summary: \"Preview designs inside the task that needs them.\", badge: \"Beta\", description: \"Frames stay in sync, so nobody reviews last week's version.\", features: [\"Live previews\", \"Comments both ways\"] },\n  { id: \"linear\", name: \"Linear\", category: \"Developer\", summary: \"Mirror issues and keep status in step.\", description: \"Issues created in either place appear in both, with status kept in step.\", features: [\"Two-way issues\", \"Cycle sync\"] },\n  { id: \"zoom\", name: \"Zoom\", category: \"Communication\", summary: \"Start a call from any task.\", description: \"Start a meeting from a task and keep its notes attached.\", features: [\"One-click meetings\", \"Notes on the task\"] },\n]\n\nfunction Tile({ name, className }: { name: string; className?: string }) {\n  return (\n    <span aria-hidden=\"true\" className={cn(\"relative flex size-12 shrink-0 items-center justify-center overflow-hidden rounded-xl border text-base font-semibold\", className)}>\n      <CoverArt seed={name} variant=\"mesh\" className=\"absolute inset-0\" />\n      <span className=\"relative\">{name[0]}</span>\n    </span>\n  )\n}\n\nconst badgeTone = { Popular: \"bg-chart-4/15 text-foreground\", New: \"bg-chart-2/15 text-foreground\", Beta: \"bg-chart-5/15 text-foreground\" }\n\nfunction Skel() {\n  return (\n    <div aria-hidden=\"true\" className=\"grid gap-4 sm:grid-cols-2 lg:grid-cols-3\">\n      {Array.from({ length: 6 }, (_, i) => (\n        <div key={i} className=\"bg-card rounded-2xl border p-5\">\n          <div className=\"flex gap-4\">\n            <Skeleton className=\"size-12 rounded-xl\" />\n            <div className=\"grid flex-1 content-center gap-2\">\n              <Skeleton className=\"h-4 w-1/2\" />\n              <Skeleton className=\"h-3 w-1/3\" />\n            </div>\n          </div>\n          <Skeleton className=\"mt-5 h-3 w-full\" />\n          <Skeleton className=\"mt-2 h-3 w-4/5\" />\n        </div>\n      ))}\n    </div>\n  )\n}\n\n/**\n * A public integrations directory: search that highlights what matched, category filters with counts, cards that show whether each is\n * connected, and a side sheet with what it does and a Connect button that handles the wait and a failure. Pictures are drawn from each name.\n */\nfunction Integrations1({\n  integrations = sample,\n  title = \"Works with the tools you already use\",\n  description = \"Connect once and your work shows up where your team already looks.\",\n  onConnect,\n  onDisconnect,\n  defaultOpenId,\n  state = \"ready\",\n  onRetry,\n  emptyState,\n  errorState,\n  className,\n  ...props\n}: Integrations1Props) {\n  const id = React.useId()\n  const list = state === \"empty\" ? [] : integrations\n  const [connected, setConnected] = React.useState<Record<string, boolean>>(() => Object.fromEntries(integrations.map((i) => [i.id, !!i.connected])))\n  const [category, setCategory] = React.useState<string | null>(null)\n  const [query, setQuery] = React.useState(\"\")\n  const [openId, setOpenId] = React.useState<string | null>(defaultOpenId ?? null)\n  const [busy, setBusy] = React.useState(false)\n  const [error, setError] = React.useState<string>()\n  const [announce, setAnnounce] = React.useState(\"\")\n  const actionRef = React.useRef<HTMLButtonElement>(null)\n  const lastOpen = React.useRef<string | null>(null)\n  if (openId) lastOpen.current = openId\n  const tokens = queryTokens(query)\n\n  const categories = React.useMemo(() => [...new Set(list.map((i) => i.category))], [list])\n  const counts = (c: string | null) => list.filter((i) => (c ? i.category === c : true) && (!tokens.length || matchesAll(`${i.name} ${i.summary} ${i.category}`, tokens))).length\n  const shown = list.filter((i) => (!category || i.category === category) && (!tokens.length || matchesAll(`${i.name} ${i.summary} ${i.category}`, tokens)))\n  const open = list.find((i) => i.id === openId)\n\n  async function act(item: Integrations1Item) {\n    const on = !connected[item.id]\n    setError(undefined)\n    setBusy(true)\n    try {\n      await (on ? onConnect : onDisconnect)?.(item)\n      setConnected((c) => ({ ...c, [item.id]: on }))\n      setAnnounce(on ? `${item.name} connected` : `${item.name} disconnected`)\n    } catch (e) {\n      setError(e instanceof Error ? e.message : `We could not ${on ? \"connect\" : \"disconnect\"} ${item.name}. Nothing was changed.`)\n    } finally {\n      setBusy(false)\n    }\n  }\n\n  const content =\n    !list.length ? (\n      <div className=\"bg-card rounded-2xl border px-6 py-16 text-center\">\n        <span aria-hidden=\"true\" className=\"bg-muted mx-auto flex size-12 items-center justify-center rounded-full\">\n          <Plug className=\"size-6\" />\n        </span>\n        <h2 className=\"mt-5 text-lg font-semibold tracking-tight\">{emptyState?.title ?? \"No integrations yet\"}</h2>\n        <p className=\"text-muted-foreground mx-auto mt-1.5 max-w-xs text-sm text-pretty\">{emptyState?.description ?? \"When an integration is added it appears here, ready to connect.\"}</p>\n        {emptyState?.action}\n      </div>\n    ) : (\n      <>\n        <div className=\"flex flex-col gap-4 sm:flex-row sm:items-center\">\n          <div className=\"bg-background focus-within:ring-ring/50 flex h-10 items-center gap-2 rounded-lg border px-3 focus-within:ring-[3px] sm:w-72\">\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)} className=\"placeholder:text-muted-foreground min-w-0 flex-1 bg-transparent text-sm outline-none\" />\n          </div>\n          <div role=\"group\" aria-label=\"Filter by category\" className=\"-mx-4 flex gap-2 overflow-x-auto px-4 pb-1 sm:mx-0 sm:flex-wrap sm:overflow-visible sm:px-0 sm:pb-0\">\n            {[null, ...categories].map((c) => (\n              <button\n                key={c ?? \"all\"}\n                type=\"button\"\n                aria-pressed={category === c}\n                onClick={() => setCategory(c)}\n                className={cn(\"focus-visible:ring-ring/50 shrink-0 rounded-full border px-3.5 py-1.5 text-sm font-medium outline-none transition-colors focus-visible:ring-[3px] motion-reduce:transition-none\", category === c ? \"bg-foreground text-background border-foreground\" : \"hover:bg-accent\")}\n              >\n                {c ?? \"All\"} <span className={cn(\"tabular-nums\", category === c ? \"opacity-70\" : \"text-muted-foreground\")}>{counts(c)}</span>\n              </button>\n            ))}\n          </div>\n        </div>\n\n        {shown.length ? (\n          <ul className=\"mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3\">\n            {shown.map((i) => (\n              <li key={i.id}>\n                <button\n                  type=\"button\"\n                  data-integration={i.id}\n                  onClick={() => {\n                    setError(undefined)\n                    setOpenId(i.id)\n                  }}\n                  className=\"bg-card hover:border-foreground/30 focus-visible:ring-ring/50 group flex h-full w-full flex-col rounded-2xl border p-5 text-start outline-none transition-[border-color,box-shadow] duration-150 hover:shadow-md focus-visible:ring-[3px] motion-reduce:transition-none\"\n                >\n                  <span className=\"flex items-start gap-4\">\n                    <Tile name={i.name} />\n                    <span className=\"min-w-0 flex-1\">\n                      <span className=\"flex flex-wrap items-center gap-x-2 gap-y-1\">\n                        <span className=\"font-semibold tracking-tight\">\n                          <MatchHighlight text={i.name} query={tokens} />\n                        </span>\n                        {i.badge ? <Badge className={cn(\"border-transparent\", badgeTone[i.badge])}>{i.badge}</Badge> : null}\n                      </span>\n                      <span className=\"text-muted-foreground mt-0.5 block text-xs\">{i.category}</span>\n                    </span>\n                    {connected[i.id] ? (\n                      <span className=\"flex shrink-0 items-center gap-1 text-xs font-medium\">\n                        <Check aria-hidden=\"true\" className=\"text-chart-2 size-3.5\" />\n                        Connected\n                      </span>\n                    ) : null}\n                  </span>\n                  <span className=\"text-muted-foreground mt-4 text-sm text-pretty\">\n                    <MatchHighlight text={i.summary} query={tokens} />\n                  </span>\n                </button>\n              </li>\n            ))}\n          </ul>\n        ) : (\n          <div className=\"mt-8 rounded-2xl border border-dashed px-6 py-14 text-center\">\n            <p className=\"font-medium\">Nothing matches {query.trim() ? `“${query.trim()}”` : \"that\"}</p>\n            <p className=\"text-muted-foreground mt-1 text-sm\">Try another word, or look in all categories.</p>\n            <Button\n              variant=\"outline\"\n              size=\"sm\"\n              className=\"mt-4\"\n              onClick={() => {\n                setQuery(\"\")\n                setCategory(null)\n              }}\n            >\n              <X aria-hidden=\"true\" />\n              Clear filters\n            </Button>\n          </div>\n        )}\n      </>\n    )\n\n  return (\n    <section data-slot=\"integrations-1\" data-state={state} aria-labelledby={`${id}-title`} className={cn(\"mx-auto w-full max-w-6xl px-4 py-14 sm:px-6 sm:py-20\", className)} {...frameProps(props)}>\n      <header className=\"mb-10 max-w-2xl\">\n        <h1 id={`${id}-title`} className=\"text-4xl font-semibold tracking-[-0.04em] text-balance sm:text-5xl\">\n          {title}\n        </h1>\n        <p className=\"text-muted-foreground mt-4 text-lg text-pretty\">{description}</p>\n      </header>\n      <StateView state={state === \"loading\" || state === \"error\" ? state : \"ready\"} onRetry={onRetry} label=\"Loading integrations\" loading={<Skel />} error={{ title: errorState?.title ?? \"Integrations could not be loaded\", description: errorState?.description ?? \"Check your connection and try again.\", ...errorState }}>\n        {content}\n      </StateView>\n\n      <Sheet open={!!open} onOpenChange={(o) => !o && !busy && setOpenId(null)}>\n        <SheetContent\n          onOpenAutoFocus={(e) => {\n            e.preventDefault()\n            actionRef.current?.focus()\n          }}\n          onCloseAutoFocus={(e) => {\n            e.preventDefault()\n            document.querySelector<HTMLElement>(`[data-integration=\"${lastOpen.current ?? \"\"}\"]`)?.focus()\n          }}\n        >\n          {open ? (\n            <>\n              <SheetHeader className=\"pb-5\">\n                <div className=\"flex items-center gap-4\">\n                  <Tile name={open.name} className=\"size-14 text-lg\" />\n                  <div className=\"min-w-0 text-start\">\n                    <SheetTitle>{open.name}</SheetTitle>\n                    <SheetDescription>{open.category}{connected[open.id] ? \" · Connected\" : \"\"}</SheetDescription>\n                  </div>\n                </div>\n              </SheetHeader>\n              <SheetBody className=\"pb-6\">\n                <p className=\"text-base text-pretty\">{open.description ?? open.summary}</p>\n                {open.features?.length ? (\n                  <>\n                    <h3 className=\"mt-6 text-sm font-semibold\">What you get</h3>\n                    <ul className=\"mt-3 grid gap-2.5\">\n                      {open.features.map((f) => (\n                        <li key={f} className=\"flex items-start gap-2.5 text-sm\">\n                          <Check aria-hidden=\"true\" className=\"text-chart-2 mt-0.5 size-4 shrink-0\" />\n                          {f}\n                        </li>\n                      ))}\n                    </ul>\n                  </>\n                ) : null}\n                {error ? (\n                  <p role=\"alert\" className=\"text-destructive mt-6 text-sm\">\n                    {error}\n                  </p>\n                ) : null}\n              </SheetBody>\n              <SheetFooter>\n                {open.docsHref ? (\n                  <a href={open.docsHref} className={buttonVariants({ variant: \"ghost\" })}>\n                    Read the docs\n                    <ArrowUpRight aria-hidden=\"true\" className=\"rtl:-scale-x-100\" />\n                  </a>\n                ) : null}\n                <Button ref={actionRef} variant={connected[open.id] ? \"outline\" : \"default\"} disabled={busy} onClick={() => void act(open)}>\n                  {busy ? <Loader2 aria-hidden=\"true\" className=\"animate-spin motion-reduce:animate-none\" /> : null}\n                  {busy ? (connected[open.id] ? \"Disconnecting\" : \"Connecting\") : connected[open.id] ? \"Disconnect\" : `Connect ${open.name}`}\n                </Button>\n              </SheetFooter>\n            </>\n          ) : null}\n        </SheetContent>\n      </Sheet>\n      <p aria-live=\"polite\" className=\"sr-only\">\n        {announce}\n      </p>\n    </section>\n  )\n}\n\nexport { Integrations1, type Integrations1Props, type Integrations1Item }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/integrations-1/integrations-1.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "integrations",
      "marketplace",
      "apps",
      "directory",
      "connect",
      "plugins",
      "partners",
      "ecosystem"
    ],
    "version": "1.0.0",
    "updated": "2026-10-18",
    "tests": true,
    "ai": {
      "summary": "An integrations directory. <Integrations1 integrations={[{ id, name, category, summary, description, features: [], badge: 'Popular' | 'New' | 'Beta', connected, docsHref }]} onConnect={(item) => Promise} onDisconnect defaultOpenId />. Categories come from the data and show counts for the current search. Reject onConnect to show its message in the sheet.",
      "whenToUse": [
        "A marketing page listing what the product connects to",
        "An in-app integrations settings page"
      ],
      "whenNotToUse": [
        "A row of partner logos (use logo-cloud-1)",
        "Step-by-step setup of one connection (use a wizard)"
      ],
      "composesWith": [
        "hero-1",
        "logo-cloud-1",
        "cta-1",
        "settings-1",
        "footer-1"
      ],
      "a11y": [
        {
          "keys": "Tab",
          "action": "Search, category buttons (pressed state announced), then each card as one button"
        },
        {
          "keys": "Enter / Space",
          "action": "Open the details sheet; Escape closes it and focus returns to the card"
        },
        {
          "keys": "Connected",
          "action": "Shown with a check and the word Connected, not only by color; changes are announced"
        }
      ],
      "customization": [
        "Pictures are drawn from the name, so no logos are needed to look finished",
        "Category counts follow the search",
        "The sheet slides in from the reading end and mirrors in right-to-left"
      ]
    },
    "states": [
      {
        "id": "loading",
        "title": "Loading",
        "example": "integrations-1-loading",
        "trigger": "state=\"loading\"",
        "note": "Skeleton cards under the real heading, announced as loading."
      },
      {
        "id": "empty",
        "title": "No integrations",
        "example": "integrations-1-empty",
        "trigger": "state=\"empty\" (or integrations={[]})",
        "note": "Explains that integrations appear here when added; emptyState sets the copy and can add a button. A search with no match says so with a Clear filters button."
      },
      {
        "id": "error",
        "title": "Could not load",
        "example": "integrations-1-error",
        "trigger": "state=\"error\"",
        "note": "An alert with Try again (onRetry). A failed connect shows its message in the sheet."
      }
    ],
    "examples": [
      "integrations-1-demo",
      "integrations-1-open",
      "integrations-1-loading",
      "integrations-1-empty",
      "integrations-1-error"
    ],
    "url": "https://ui.ballmac.com/components/integrations-1"
  },
  "categories": [
    "blocks",
    "integrations"
  ],
  "type": "registry:block"
}