{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"integrations","type":"registry:component","title":"Integrations","description":"Settings → Integrations, done properly: Connect waits on the provider's own window, then folds away as the account it connected opens in its place.","author":"Hairline UI","dependencies":["lucide-react"],"registryDependencies":["https://hairlineui.com/r/text-morph.json?as=dependency","https://hairlineui.com/r/number-roll.json?as=dependency","https://hairlineui.com/r/status-button.json?as=dependency"],"files":[{"path":"registry/integrations.tsx","type":"registry:component","content":"// Hairline UI · integrations · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { ArrowUpRight, Search, Unplug } from \"lucide-react\";\nimport { ConfirmAction } from \"./confirm-action\";\nimport { NumberRoll } from \"./number-roll\";\nimport { StatusButton, type ActionStatus } from \"./status-button\";\nimport { TextMorph } from \"./text-morph\";\nimport { useRowMotion } from \"./use-row-motion\";\n\n/* ─────────────────────────────────────────────────────────\n * INTEGRATIONS: Settings → Integrations, done properly\n *\n *   connect   Connect says Connecting while the provider's own\n *             window is open; when it comes back, the button\n *             folds away and the account it connected opens in\n *             its place, a check drawing beside the name\n *   scopes    what the connection may do opens under the row\n *   refused   a closed window or a refusal shakes the button,\n *             says Try again, and the reason lifts in under the\n *             description\n *   filter    category tabs (the highlight slides) and search;\n *             rows that stay glide into place, the rest fold\n *   disconnect the plug stretches into a confirm; the account\n *             folds back into Connect\n *\n * Your server does OAuth; openOAuthWindow (oauth-window.ts)\n * opens the provider's page in a small window and resolves\n * when your callback says it's done. Fits Nango, Pipedream\n * Connect or your own OAuth routes.\n * ───────────────────────────────────────────────────────── */\n\nexport { openOAuthWindow, type OAuthWindowOptions } from \"./oauth-window\";\n\nexport interface IntegrationAccount {\n  /** \"Northwind workspace\", \"maya@fernhill.co\". */\n  name: string;\n  /** A second line: \"#ops, #alerts\" or \"Connected 2 days ago\". */\n  detail?: string;\n  image?: string;\n}\n\nexport interface Integration {\n  id: string;\n  name: string;\n  description?: string;\n  /** The provider's mark, about 18px. */\n  icon?: React.ReactNode;\n  category?: string;\n  /** Set when it's connected. */\n  account?: IntegrationAccount | null;\n  /** What the connection may do, shown once connected. */\n  scopes?: string[];\n}\n\nexport interface IntegrationsProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"defaultValue\" | \"onChange\"> {\n  value?: Integration[];\n  defaultValue?: Integration[];\n  onValueChange?: (integrations: Integration[]) => void;\n  /** Runs the provider's sign-in and returns the account it connected. Throw to say why not. */\n  onConnect: (integration: Integration) => Promise<IntegrationAccount>;\n  onDisconnect?: (integration: Integration) => Promise<unknown>;\n  /** A link to the integration's own settings once connected. */\n  settingsHref?: (integration: Integration) => string | undefined;\n  title?: string;\n  description?: string;\n}\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst FOCUS = \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40\";\nconst NONE: Integration[] = [];\nconst ALL = \"All\";\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);\n\nconst message = (e: unknown, fallback: string) => (e instanceof Error && e.message ? e.message : fallback);\n\n/** A piece that opens sideways (0fr ↔ 1fr), staying on the baseline. */\nfunction Side({ open, reduced, children, className = \"\" }: { open: boolean; reduced: boolean; children: React.ReactNode; className?: string }) {\n  return (\n    <span\n      className={`inline-grid ${className}`}\n      inert={!open}\n      aria-hidden={!open || undefined}\n      style={{\n        gridTemplateColumns: open ? \"1fr\" : \"0fr\",\n        opacity: open ? 1 : 0,\n        filter: open || reduced ? \"none\" : \"blur(3px)\",\n        transition: reduced ? \"none\" : `grid-template-columns 460ms ${EASE}, opacity ${open ? 320 : 160}ms ${EASE} ${open ? 120 : 0}ms, filter 320ms ${EASE}`,\n      }}\n    >\n      <span className=\"flex min-w-0 items-center justify-end [clip-path:inset(-6px_-2px)]\">{children}</span>\n    </span>\n  );\n}\n\nfunction Fold({ open, reduced, children }: { open: boolean; reduced: boolean; children: React.ReactNode }) {\n  const kept = React.useRef(children);\n  if (open) kept.current = children;\n  return (\n    <div className=\"grid\" inert={!open} aria-hidden={!open || undefined} style={{ gridTemplateRows: open ? \"1fr\" : \"0fr\", opacity: open ? 1 : 0, transition: reduced ? \"none\" : `grid-template-rows 420ms ${EASE}, opacity ${open ? 300 : 160}ms ${EASE}` }}>\n      <div className=\"min-h-0 min-w-0 overflow-clip\">{open ? children : kept.current}</div>\n    </div>\n  );\n}\n\nfunction Avatar({ account }: { account: IntegrationAccount }) {\n  if (account.image)\n    // eslint-disable-next-line @next/next/no-img-element\n    return <img src={account.image} alt=\"\" className=\"size-5 shrink-0 rounded-full object-cover shadow-[0_0_0_1px_var(--border)]\" />;\n  return (\n    <span aria-hidden=\"true\" className=\"grid size-5 shrink-0 place-items-center rounded-full bg-muted text-[9.5px] font-medium text-muted-foreground shadow-[inset_0_0_0_1px_var(--border)]\">\n      {account.name.slice(0, 1).toUpperCase()}\n    </span>\n  );\n}\n\ninterface RowProps {\n  item: Integration;\n  status: ActionStatus;\n  error?: string;\n  href?: string;\n  canDisconnect: boolean;\n  reduced: boolean;\n  act: React.RefObject<{ connect: (i: Integration) => void; disconnect: (i: Integration) => Promise<void> }>;\n}\n\nconst Row = React.memo(function Row({ item, status, error, href, canDisconnect, reduced, act }: RowProps) {\n  const connected = Boolean(item.account);\n  // The account stays on screen while it folds back into Connect.\n  const kept = React.useRef(item.account ?? null);\n  if (item.account) kept.current = item.account;\n  const account = item.account ?? kept.current;\n\n  return (\n    <li data-row-id={item.id} className=\"border-t border-border first:border-t-0\">\n      <div className=\"flex items-center gap-3 px-4 py-3 @md:px-5\">\n        <span aria-hidden=\"true\" className=\"grid size-9 shrink-0 place-items-center rounded-[10px] bg-background text-foreground shadow-[inset_0_0_0_1px_var(--border)] [&_svg]:size-[18px]\">\n          {item.icon}\n        </span>\n        <div className=\"min-w-0 flex-1\">\n          <p className=\"flex items-center gap-2 text-[13.5px] font-medium tracking-[-0.01em]\">\n            <span className=\"truncate\">{item.name}</span>\n            {href && connected && (\n              <a href={href} aria-label={`${item.name} settings`} className={`inline-flex shrink-0 items-center gap-0.5 rounded-sm text-[11.5px] font-normal text-muted-foreground underline-offset-2 hover:text-foreground hover:underline ${FOCUS}`}>\n                <span className=\"hidden @md:inline\">Settings</span>\n                <ArrowUpRight className=\"size-3\" strokeWidth={2} aria-hidden=\"true\" />\n              </a>\n            )}\n          </p>\n          <div className=\"relative mt-0.5 text-[12px] leading-[1.45] text-muted-foreground\">\n            <p className=\"truncate transition-[opacity,filter] duration-200\" style={{ opacity: error ? 0 : 1, filter: error && !reduced ? \"blur(3px)\" : \"none\" }}>\n              {item.description}\n            </p>\n            <p\n              aria-hidden={!error || undefined}\n              className=\"pointer-events-none absolute inset-x-0 top-0 truncate text-red-600 dark:text-red-400\"\n              style={{ opacity: error ? 1 : 0, transform: error || reduced ? \"none\" : \"translateY(4px)\", transition: reduced ? \"none\" : `opacity 260ms ${EASE} ${error ? 80 : 0}ms, transform 360ms ${EASE}` }}\n            >\n              {error}\n            </p>\n          </div>\n        </div>\n\n        {/* Connect and the connected account share the corner: one folds as the other opens. */}\n        <div className=\"flex shrink-0 items-center\">\n          <Side open={!connected} reduced={reduced}>\n            <StatusButton\n              size=\"sm\"\n              variant=\"outline\"\n              status={status}\n              labels={{ idle: \"Connect\", pending: \"Connecting\", success: \"Connected\", error: \"Try again\" }}\n              onClick={() => act.current.connect(item)}\n              aria-label={`Connect ${item.name}`}\n            />\n          </Side>\n          <Side open={connected} reduced={reduced}>\n            {account && (\n              <span className=\"flex items-center gap-1.5\">\n                <span className=\"flex max-w-[7.5rem] items-center gap-1.5 rounded-full py-1 pr-2.5 pl-1 text-[12px] shadow-[inset_0_0_0_1px_var(--border)] @sm:max-w-[11rem] @md:max-w-[14rem]\">\n                  <Avatar account={account} />\n                  <span className=\"truncate font-medium\">{account.name}</span>\n                  <svg viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden=\"true\" className=\"size-3.5 shrink-0 text-emerald-600 dark:text-emerald-400\">\n                    <path\n                      d=\"M3.5 8.5 6.5 11.5 12.5 4.5\"\n                      stroke=\"currentColor\"\n                      strokeWidth=\"1.9\"\n                      strokeLinecap=\"round\"\n                      strokeLinejoin=\"round\"\n                      pathLength={1}\n                      strokeDasharray={1}\n                      style={{ strokeDashoffset: connected ? 0 : 1, transition: connected && !reduced ? `stroke-dashoffset 420ms ${EASE} 380ms` : \"none\" }}\n                    />\n                  </svg>\n                  <span className=\"sr-only\">connected</span>\n                </span>\n                {canDisconnect && (\n                  <ConfirmAction\n                    icon={<Unplug strokeWidth={1.75} />}\n                    label={`Disconnect ${item.name}`}\n                    labels={{ idle: \"Disconnect\", pending: \"Disconnecting\", success: \"Disconnected\", error: \"Try again\" }}\n                    onConfirm={() => act.current.disconnect(item)}\n                  />\n                )}\n              </span>\n            )}\n          </Side>\n        </div>\n      </div>\n\n      <Fold open={connected && Boolean(item.scopes?.length || account?.detail)} reduced={reduced}>\n        <div className=\"flex flex-wrap items-center gap-1.5 px-4 pb-3 pl-16 text-[11.5px] text-muted-foreground @md:px-5 @md:pl-[4.25rem]\">\n          {account?.detail && <span className=\"mr-1\">{account.detail}</span>}\n          {item.scopes?.map((s) => (\n            <span key={s} className=\"rounded-full px-2 py-0.5 shadow-[inset_0_0_0_1px_var(--border)]\">\n              {s}\n            </span>\n          ))}\n        </div>\n      </Fold>\n    </li>\n  );\n});\n\nexport function Integrations({\n  value,\n  defaultValue = NONE,\n  onValueChange,\n  onConnect,\n  onDisconnect,\n  settingsHref,\n  title = \"Integrations\",\n  description = \"Connect the tools your team already uses.\",\n  className = \"\",\n  ...props\n}: IntegrationsProps) {\n  const reduced = useReducedMotion();\n  const [inner, setInner] = React.useState(defaultValue);\n  const items = value ?? inner;\n  const [status, setStatus] = React.useState<Record<string, ActionStatus>>({});\n  const [errors, setErrors] = React.useState<Record<string, string>>({});\n  const [category, setCategory] = React.useState(ALL);\n  const [query, setQuery] = React.useState(\"\");\n  const [announce, setAnnounce] = React.useState(\"\");\n  const listRef = React.useRef<HTMLUListElement>(null);\n  const ghostRef = React.useRef<HTMLDivElement>(null);\n  const tabRefs = React.useRef<Record<string, HTMLButtonElement | null>>({});\n  const [bar, setBar] = React.useState({ left: 0, width: 0, ready: false });\n  const latest = React.useRef(items);\n  latest.current = items;\n\n  const commit = (next: Integration[]) => {\n    if (value === undefined) setInner(next);\n    onValueChange?.(next);\n  };\n  const drop = <T,>(o: Record<string, T>, key: string) => Object.fromEntries(Object.entries(o).filter(([k]) => k !== key)) as Record<string, T>;\n\n  const categories = React.useMemo(() => {\n    const seen = [...new Set(items.map((i) => i.category).filter(Boolean) as string[])];\n    return seen.length > 1 ? [ALL, ...seen] : [];\n  }, [items]);\n  const q = query.trim().toLowerCase();\n  const shown = items.filter((i) => (category === ALL || i.category === category) && (!q || i.name.toLowerCase().includes(q) || i.description?.toLowerCase().includes(q)));\n  const ranks = React.useMemo(() => new Map(items.map((i, n) => [i.id, n])), [items]);\n  useRowMotion(listRef, { reduced, ranks, ghosts: ghostRef });\n  const connectedCount = items.filter((i) => i.account).length;\n\n  React.useLayoutEffect(() => {\n    const b = tabRefs.current[category];\n    if (!b) return;\n    setBar((p) => (p.left === b.offsetLeft && p.width === b.offsetWidth ? p : { left: b.offsetLeft, width: b.offsetWidth, ready: p.width > 0 }));\n  }, [category, categories.length]);\n\n  const act = React.useRef<RowProps[\"act\"][\"current\"]>(null!);\n  act.current = {\n    // Called straight from the click, so a window opened inside onConnect isn't blocked.\n    connect: (item) => {\n      if (status[item.id] === \"pending\") return;\n      setStatus((s) => ({ ...s, [item.id]: \"pending\" }));\n      setErrors((e) => drop(e, item.id));\n      onConnect(item).then(\n        (account) => {\n          commit(latest.current.map((i) => (i.id === item.id ? { ...i, account } : i)));\n          setStatus((s) => drop(s, item.id));\n          setAnnounce(`${item.name} connected as ${account.name}.`);\n        },\n        (e) => {\n          const why = message(e, `${item.name} couldn't be connected.`);\n          setStatus((s) => ({ ...s, [item.id]: \"error\" }));\n          setErrors((x) => ({ ...x, [item.id]: why }));\n          setAnnounce(why);\n          window.setTimeout(() => {\n            setStatus((s) => (s[item.id] === \"error\" ? drop(s, item.id) : s));\n            setErrors((x) => (x[item.id] === why ? drop(x, item.id) : x));\n          }, 4200);\n        },\n      );\n    },\n    disconnect: async (item) => {\n      await onDisconnect?.(item);\n      setAnnounce(`${item.name} disconnected.`);\n      window.setTimeout(() => commit(latest.current.map((i) => (i.id === item.id ? { ...i, account: null } : i))), 600);\n    },\n  };\n\n  return (\n    <div className={`@container w-full rounded-[22px] border border-border bg-background text-foreground ${className}`} {...props}>\n      <div className=\"px-4 pt-4 pb-3 @md:px-5 @md:pt-5\">\n        <p className=\"flex items-baseline gap-2 text-[14px] font-medium tracking-[-0.01em]\">\n          {title}\n          <span className=\"inline-flex items-baseline text-[12px] font-normal text-muted-foreground tabular-nums\">\n            <NumberRoll value={connectedCount} />\n            &nbsp;connected\n          </span>\n        </p>\n        <p className=\"mt-0.5 text-[12px] text-muted-foreground\">{description}</p>\n      </div>\n\n      {(categories.length > 0 || items.length > 6) && (\n        <div className=\"flex flex-wrap items-center gap-2 px-4 pb-3 @md:px-5\">\n          {categories.length > 0 && (\n            <div role=\"tablist\" aria-label=\"Categories\" className=\"relative -mx-1 flex max-w-full items-center gap-0.5 overflow-x-auto px-1 [scrollbar-width:none]\">\n              <span\n                aria-hidden=\"true\"\n                className=\"absolute top-0 left-0 h-7 rounded-full bg-accent shadow-[inset_0_0_0_1px_var(--border)]\"\n                style={{ width: bar.width, transform: `translateX(${bar.left}px)`, transition: bar.ready && !reduced ? `transform 380ms ${EASE}, width 380ms ${EASE}` : \"none\" }}\n              />\n              {categories.map((c) => (\n                <button\n                  key={c}\n                  ref={(el) => {\n                    tabRefs.current[c] = el;\n                  }}\n                  role=\"tab\"\n                  aria-selected={category === c}\n                  tabIndex={category === c ? 0 : -1}\n                  onClick={() => setCategory(c)}\n                  onKeyDown={(e) => {\n                    const step = e.key === \"ArrowRight\" ? 1 : e.key === \"ArrowLeft\" ? -1 : 0;\n                    if (!step) return;\n                    e.preventDefault();\n                    const next = categories[(categories.indexOf(c) + step + categories.length) % categories.length];\n                    setCategory(next);\n                    tabRefs.current[next]?.focus();\n                  }}\n                  className={`relative h-7 shrink-0 cursor-pointer rounded-full px-3 text-[12px] font-medium whitespace-nowrap transition-colors ${FOCUS} ${category === c ? \"text-foreground\" : \"text-muted-foreground hover:text-foreground\"}`}\n                >\n                  {c}\n                </button>\n              ))}\n            </div>\n          )}\n          <label className=\"ml-auto flex h-7 min-w-[9rem] flex-1 items-center gap-1.5 rounded-full px-2.5 shadow-[inset_0_0_0_1px_var(--border)] transition-shadow focus-within:shadow-[inset_0_0_0_1px_var(--ring)] @lg:max-w-[14rem]\">\n            <Search className=\"size-3.5 shrink-0 text-muted-foreground\" strokeWidth={1.75} aria-hidden=\"true\" />\n            <span className=\"sr-only\">Search integrations</span>\n            <input\n              type=\"search\"\n              value={query}\n              onChange={(e) => setQuery(e.target.value)}\n              placeholder=\"Search\"\n              className=\"h-full min-w-0 flex-1 bg-transparent text-[16px] text-foreground outline-none placeholder:text-muted-foreground/70 [@media(pointer:fine)]:text-[12.5px] [&::-webkit-search-cancel-button]:hidden\"\n            />\n          </label>\n        </div>\n      )}\n\n      <div className=\"relative border-t border-border\">\n        <ul ref={listRef} aria-label={title} className=\"relative\">\n          {shown.map((i) => (\n            <Row\n              key={i.id}\n              item={i}\n              status={status[i.id] ?? \"idle\"}\n              error={errors[i.id]}\n              href={i.account ? settingsHref?.(i) : undefined}\n              canDisconnect={Boolean(onDisconnect)}\n              reduced={reduced}\n              act={act}\n            />\n          ))}\n        </ul>\n        <div ref={ghostRef} aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0\" />\n        <Fold open={!shown.length} reduced={reduced}>\n          <p className=\"px-4 py-6 text-center text-[12.5px] text-muted-foreground\">Nothing matches “{query}”.</p>\n        </Fold>\n      </div>\n\n      <span className=\"sr-only\" role=\"status\">\n        {announce}\n      </span>\n    </div>\n  );\n}\n"},{"path":"registry/oauth-window.ts","type":"registry:component","content":"// Hairline UI · integrations · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n/* ─────────────────────────────────────────────────────────\n * OAUTH WINDOW: open a provider's sign-in in a small window\n * and wait for your callback page to say how it went\n *\n *   open      a centred popup on the provider's authorise URL.\n *             Call it straight from the click (before any await)\n *             or the browser will block it\n *   done      your callback page (same origin as the app) posts\n *             { type: \"oauth:done\", ...whatever you need } to\n *             window.opener, and on a BroadcastChannel as well,\n *             then closes itself\n *   closed    if the window closes without a message, the\n *             promise rejects so the row can say so\n *\n * The callback page, for example /integrations/callback:\n *\n *   const data = { type: \"oauth:done\", account: { name: \"Northwind\" } };\n *   window.opener?.postMessage(data, location.origin);\n *   new BroadcastChannel(\"oauth\").postMessage(data);\n *   window.close();\n * ───────────────────────────────────────────────────────── */\n\nexport interface OAuthWindowOptions {\n  /** Window name; one per provider keeps a second click on the same window. */\n  name?: string;\n  width?: number;\n  height?: number;\n  /** The message type your callback page posts. */\n  type?: string;\n  /** Give up after this long (ms). */\n  timeout?: number;\n}\n\nexport function openOAuthWindow<T extends Record<string, unknown> = Record<string, unknown>>(url: string, options: OAuthWindowOptions = {}): Promise<T> {\n  const { name = \"oauth\", width = 520, height = 680, type = \"oauth:done\", timeout = 10 * 60_000 } = options;\n  const left = Math.round(window.screenX + (window.outerWidth - width) / 2);\n  const top = Math.round(window.screenY + (window.outerHeight - height) / 2.5);\n  const popup = window.open(url, name, `popup=yes,width=${width},height=${height},left=${left},top=${top}`);\n  if (!popup) return Promise.reject(new Error(\"The sign-in window was blocked. Allow pop-ups for this site and try again.\"));\n  popup.focus();\n\n  return new Promise<T>((resolve, reject) => {\n    const channel = \"BroadcastChannel\" in window ? new BroadcastChannel(\"oauth\") : null;\n    let settled = false;\n    let closedAt = 0;\n\n    const finish = (data: unknown) => {\n      if (settled) return;\n      const msg = data as { type?: string; error?: string } | null;\n      if (!msg || msg.type !== type) return;\n      settled = true;\n      cleanup();\n      if (msg.error) reject(new Error(String(msg.error)));\n      else resolve(msg as T);\n    };\n    const onMessage = (e: MessageEvent) => {\n      if (e.origin === window.location.origin) finish(e.data);\n    };\n    const onChannel = (e: MessageEvent) => finish(e.data);\n    const poll = window.setInterval(() => {\n      if (!popup.closed) return;\n      // The callback posts right before it closes; give that message a moment to land.\n      if (!closedAt) closedAt = Date.now();\n      else if (Date.now() - closedAt > 1200 && !settled) {\n        settled = true;\n        cleanup();\n        reject(new Error(\"The window was closed before it finished.\"));\n      }\n    }, 300);\n    const limit = window.setTimeout(() => {\n      if (settled) return;\n      settled = true;\n      cleanup();\n      reject(new Error(\"That took too long. Try again.\"));\n    }, timeout);\n\n    function cleanup() {\n      window.removeEventListener(\"message\", onMessage);\n      channel?.removeEventListener(\"message\", onChannel);\n      channel?.close();\n      window.clearInterval(poll);\n      window.clearTimeout(limit);\n      try {\n        if (!popup!.closed) popup!.close();\n      } catch {\n        /* Already gone. */\n      }\n    }\n\n    window.addEventListener(\"message\", onMessage);\n    channel?.addEventListener(\"message\", onChannel);\n  });\n}\n"},{"path":"registry/confirm-action.tsx","type":"registry:component","content":"// Hairline UI · integrations · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { StatusButton, type ActionStatus } from \"./status-button\";\n\n/* ─────────────────────────────────────────────────────────\n * CONFIRM ACTION: an icon that asks before it acts\n *\n *   ask       a click and the icon's own circle stretches into\n *             a pill: the confirm button opens out to its left\n *             and the icon turns into a cross that takes it back\n *   act       the confirm button is a status button, so it says\n *             Revoking, Revoked or Try again in place\n *   leave     Escape, the cross, or a click or focus anywhere\n *             else folds it back into the icon\n *\n * Nothing opens over the row it belongs to; the question sits\n * where the icon was.\n * ───────────────────────────────────────────────────────── */\n\nexport interface ConfirmActionProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"onClick\"> {\n  icon: React.ReactNode;\n  /** The icon button's accessible name, e.g. \"Revoke Production key\". */\n  label: string;\n  /** The confirm button's words per state, e.g. { idle: \"Revoke\", pending: \"Revoking\", success: \"Revoked\" }. */\n  labels?: Partial<Record<ActionStatus, string>>;\n  /** \"danger\" makes the confirm red. */\n  tone?: \"danger\" | \"default\";\n  /** Runs once on confirm. Throw (or reject) to show Try again. */\n  onConfirm: () => Promise<unknown> | unknown;\n  /** Back to the icon this long after success (ms); 0 keeps \"done\" until the row goes. */\n  resetAfter?: number;\n  disabled?: boolean;\n}\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst FOCUS = \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40\";\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);\n\nfunction Cross() {\n  return (\n    <svg viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden=\"true\" className=\"size-3.5\">\n      <path d=\"M4.5 4.5l7 7M11.5 4.5l-7 7\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\" />\n    </svg>\n  );\n}\n\nexport function ConfirmAction({\n  icon,\n  label,\n  labels,\n  tone = \"danger\",\n  onConfirm,\n  resetAfter = 0,\n  disabled = false,\n  className = \"\",\n  ...props\n}: ConfirmActionProps) {\n  const reduced = useReducedMotion();\n  const [asking, setAsking] = React.useState(false);\n  const [status, setStatus] = React.useState<ActionStatus>(\"idle\");\n  const wrapRef = React.useRef<HTMLDivElement>(null);\n  const iconRef = React.useRef<HTMLButtonElement>(null);\n  const run = React.useRef(0);\n  const busy = status === \"pending\" || status === \"success\";\n\n  const cancel = React.useCallback(\n    (refocus: boolean) => {\n      if (status === \"pending\") return;\n      run.current++;\n      setAsking(false);\n      setStatus(\"idle\");\n      if (refocus) iconRef.current?.focus({ preventScroll: true });\n    },\n    [status],\n  );\n\n  // The question is open: its button takes focus once it's there to take it.\n  React.useEffect(() => {\n    if (asking && status === \"idle\") wrapRef.current?.querySelector<HTMLButtonElement>(\"[data-confirm]\")?.focus({ preventScroll: true });\n  }, [asking, status]);\n\n  React.useEffect(() => {\n    if (!asking) return;\n    const onDown = (e: PointerEvent) => {\n      if (!wrapRef.current?.contains(e.target as Node)) cancel(false);\n    };\n    document.addEventListener(\"pointerdown\", onDown);\n    return () => document.removeEventListener(\"pointerdown\", onDown);\n  }, [asking, cancel]);\n\n  const confirm = async () => {\n    if (busy) return;\n    const mine = ++run.current;\n    setStatus(\"pending\");\n    try {\n      await onConfirm();\n      if (run.current === mine) setStatus(\"success\");\n    } catch {\n      if (run.current === mine) setStatus(\"error\");\n    }\n  };\n\n  const t = `${reduced ? 0 : 420}ms ${EASE}`;\n  const red = tone === \"danger\" ? \"text-red-600! hover:bg-red-500/10! dark:text-red-400!\" : \"\";\n\n  return (\n    <div\n      ref={wrapRef}\n      className={`relative inline-flex h-7 shrink-0 items-center rounded-full ${className}`}\n      onKeyDown={(e) => {\n        if (e.key === \"Escape\" && asking) {\n          e.preventDefault();\n          e.stopPropagation();\n          cancel(true);\n        }\n      }}\n      onBlur={(e) => {\n        if (asking && !busy && document.hasFocus() && !wrapRef.current?.contains(e.relatedTarget as Node)) cancel(false);\n      }}\n      {...props}\n    >\n      {/* The icon's circle, stretched into the pill round the question. */}\n      <span\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute inset-0 rounded-full\"\n        style={{ boxShadow: `inset 0 0 0 1px ${asking ? \"var(--border)\" : \"transparent\"}`, transition: `box-shadow ${t}` }}\n      />\n      <span\n        className=\"grid\"\n        inert={!asking}\n        style={{\n          gridTemplateColumns: asking ? \"1fr\" : \"0fr\",\n          opacity: asking ? 1 : 0,\n          filter: asking || reduced ? \"none\" : \"blur(3px)\",\n          transition: `grid-template-columns ${t}, opacity ${reduced ? 0 : asking ? 320 : 160}ms ${EASE}, filter ${t}`,\n        }}\n      >\n        <span className=\"min-w-0 [clip-path:inset(-2px_0_-2px_0)]\">\n          <span className=\"flex w-max items-center pl-0.5\">\n            <StatusButton\n              data-confirm=\"\"\n              size=\"sm\"\n              variant=\"ghost\"\n              status={status}\n              labels={{ idle: \"Confirm\", pending: \"Working\", success: \"Done\", error: \"Try again\", ...labels }}\n              onClick={confirm}\n              onReset={resetAfter ? () => cancel(false) : undefined}\n              resetAfter={resetAfter || undefined}\n              className={`h-[26px]! ${status === \"error\" ? \"\" : red}`}\n            />\n          </span>\n        </span>\n      </span>\n      <button\n        ref={iconRef}\n        type=\"button\"\n        aria-label={asking ? \"Cancel\" : label}\n        aria-expanded={asking}\n        disabled={disabled}\n        onClick={() => (asking ? cancel(true) : setAsking(true))}\n        className={`relative grid size-7 shrink-0 cursor-pointer place-items-center rounded-full text-muted-foreground transition-colors duration-150 hover:bg-accent hover:text-foreground disabled:cursor-default disabled:opacity-40 aria-disabled:cursor-default ${FOCUS}`}\n        aria-disabled={busy || undefined}\n      >\n        {[icon, <Cross key=\"x\" />].map((glyph, i) => {\n          const on = (i === 1) === asking;\n          return (\n            <span\n              key={i}\n              aria-hidden=\"true\"\n              className=\"absolute inset-0 grid place-items-center [&_svg]:size-3.5\"\n              style={{\n                opacity: on ? 1 : 0,\n                transform: on ? \"none\" : \"scale(0.6)\",\n                filter: on || reduced ? \"none\" : \"blur(3px)\",\n                transition: reduced ? \"none\" : `opacity 260ms ${EASE}, transform 380ms ${EASE}, filter 260ms ${EASE}`,\n              }}\n            >\n              {glyph}\n            </span>\n          );\n        })}\n      </button>\n    </div>\n  );\n}\n"},{"path":"registry/use-row-motion.ts","type":"registry:component","content":"// Hairline UI · integrations · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\n\n/* ─────────────────────────────────────────────────────────\n * ROW MOTION: rows that keep their place on screen\n *\n *   glide     after any render (a sort, a filter, new data from\n *             the server) every row that moved glides from its\n *             old place to its new one. Glides are additive, so\n *             a change that lands mid-glide adds its own offset\n *             instead of restarting anything\n *   travel    a row that sorts onto the page slides in from the\n *             side where it ranked before; one that sorts off\n *             slides out toward its new rank\n *   enter     a row that's new to the list fades in where it\n *             lands while the rows below glide clear of it\n *   leave     a row that's gone folds away where it stood. Its\n *             copy is the row's own element, detached by React\n *             and moved into a layer React never renders into,\n *             so a leaving row costs no render at all\n *   stagger   a sort can ask for the rows to set off one after\n *             another, top first\n *\n * Positions are layout offsets (transforms ignored), kept after\n * every render and whenever the list changes size (a row\n * opening its detail), so they're never stale. Rows are found\n * by data-row-id; ranks are their places in the whole sorted\n * list, not just the page.\n * ───────────────────────────────────────────────────────── */\n\ntype Seen = { top: number; height: number; el: HTMLElement };\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst GLIDE = 480;\n\nexport function useRowMotion(\n  container: React.RefObject<HTMLElement | null>,\n  { reduced, resetKey, ranks, ghosts }: { reduced: boolean; resetKey?: string | number; ranks: ReadonlyMap<string, number>; ghosts: React.RefObject<HTMLElement | null> },\n) {\n  const seen = React.useRef(new Map<string, Seen>());\n  const lastRanks = React.useRef(ranks);\n  const lastKey = React.useRef(resetKey);\n  const staggerNext = React.useRef(false);\n\n  const read = React.useCallback(() => {\n    const out = new Map<string, Seen>();\n    container.current?.querySelectorAll<HTMLElement>(\"[data-row-id]\").forEach((el) => out.set(el.dataset.rowId!, { top: el.offsetTop, height: el.offsetHeight, el }));\n    return out;\n  }, [container]);\n\n  /** The row React just removed, back on screen where it stood, leaving: sliding toward where it went, or folding. */\n  const leave = (was: Seen, dy: number) => {\n    const layer = ghosts.current;\n    const el = was.el;\n    if (!layer || el.isConnected) return;\n    // No getAnimations() here (it flushes style for the whole page): the exit below is added last and replaces any\n    // glide still on the element.\n    el.removeAttribute(\"data-row-id\");\n    el.removeAttribute(\"tabindex\");\n    el.setAttribute(\"aria-hidden\", \"true\");\n    el.inert = true;\n    Object.assign(el.style, { position: \"absolute\", left: \"0\", right: \"0\", top: `${was.top}px`, height: `${was.height}px`, margin: \"0\", overflow: \"hidden\" });\n    layer.appendChild(el);\n    const a = el.animate(\n      dy\n        ? [\n            { transform: \"translateY(0px)\", opacity: 1, filter: \"blur(0px)\" },\n            { transform: `translateY(${dy}px)`, opacity: 0, filter: \"blur(3px)\" },\n          ]\n        : [\n            { opacity: 1, transform: \"none\", filter: \"blur(0px)\" },\n            { opacity: 0, transform: \"scale(0.985)\", filter: \"blur(4px)\" },\n          ],\n      { duration: dy ? 360 : 280, easing: EASE, fill: \"forwards\" },\n    );\n    a.onfinish = a.oncancel = () => el.remove();\n  };\n\n  React.useLayoutEffect(() => {\n    const el = container.current;\n    if (!el) return;\n    const before = seen.current;\n    const after = read();\n    const prevRanks = lastRanks.current;\n    seen.current = after;\n    lastRanks.current = ranks;\n    const stagger = staggerNext.current;\n    staggerNext.current = false;\n    if (lastKey.current !== resetKey) {\n      // A different page: the caller slides the whole body; nothing glides and nothing folds.\n      lastKey.current = resetKey;\n      return;\n    }\n    if (reduced || !before.size) return;\n\n    // A typical row's height. Rows that come onto or leave the page only hint at the way they went (a row and a\n    // quarter at most), so the list never shows holes while they travel.\n    const heights = [...after.values()].map((b) => b.height).sort((a, b) => a - b);\n    const rowH = heights[Math.floor(heights.length / 2)] ?? 48;\n    const clamp = (dy: number) => Math.max(-rowH * 1.25, Math.min(rowH * 1.25, dy));\n\n    let order = 0;\n    after.forEach((now, id) => {\n      const row = now.el;\n      const was = before.get(id);\n      const delay = stagger ? Math.min(order++ * 16, 200) : 0;\n      if (was) {\n        const dy = was.top - now.top;\n        if (Math.abs(dy) < 0.5) return;\n        row.animate([{ transform: `translateY(${dy}px)`, zIndex: 1 }, { transform: \"translateY(0px)\", zIndex: 1 }], { duration: GLIDE, delay, easing: EASE, composite: \"add\", fill: \"backwards\" });\n        return;\n      }\n      const from = prevRanks.get(id);\n      const to = ranks.get(id);\n      if (from !== undefined && to !== undefined) {\n        // It ranked somewhere off the page: it comes in from that side.\n        row.animate(\n          [\n            { transform: `translateY(${clamp((from - to) * rowH)}px)`, opacity: 0, filter: \"blur(3px)\" },\n            { transform: \"translateY(0px)\", opacity: 1, filter: \"blur(0px)\" },\n          ],\n          { duration: GLIDE, delay: delay / 2, easing: EASE, fill: \"backwards\" },\n        );\n        return;\n      }\n      row.animate(\n        [\n          { opacity: 0, filter: \"blur(4px)\" },\n          { opacity: 1, filter: \"blur(0px)\" },\n        ],\n        { duration: 420, delay: 90, easing: EASE, fill: \"backwards\" },\n      );\n    });\n\n    // Still in the list, just not on this page: its copy slides toward where it went. Gone for good: it folds.\n    before.forEach((was, id) => {\n      if (after.has(id)) return;\n      const from = prevRanks.get(id);\n      const to = ranks.get(id);\n      leave(was, from !== undefined && to !== undefined ? clamp((to - from) * rowH) || rowH : 0);\n    });\n  });\n\n  // The list changed size without a render of ours (a detail opening by transition): keep the positions true.\n  React.useEffect(() => {\n    const el = container.current;\n    if (!el) return;\n    const observer = new ResizeObserver(() => {\n      seen.current = read();\n    });\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, [container, read]);\n\n  return {\n    /** The next change (a sort) sets the rows off one after another. */\n    stagger: () => {\n      staggerNext.current = true;\n    },\n  };\n}\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/integrations. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}