Blocks

Integration Grid

A four-state integrations directory: debounced search and category facets that really filter, a truthful result count, and per-card connect / disconnect with pending, failure and retry.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  Calendar,
  ChartLine,
  Check,
  CreditCard,
  Database,
  GitBranch,
  HardDrive,
  LifeBuoy,
  Loader2,
  type LucideIcon,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/integration-grid.json

Prompt

Build a React + TypeScript + Tailwind "IntegrationGrid" block (lucide-react
icons, zod, and a useDebounceValue(value, delay) hook that returns the value
only after it has stopped changing for `delay` ms).

Contract
- A zod schema is the single source of truth:
  { status: "loading" | "empty" | "error" | "ready"; heading?; subheading?;
    items: { id, name, description, category, icon, connection }[] }
  where `icon` is an enum of NEUTRAL glyph keys (chat / calendar / storage /
  code / payments …) mapped to lucide icons — never a brand mark, never a
  brand colour — and `connection` is the SERVER-side truth:
  "disconnected" | "connecting" | "connected" | "error".
- Props = z.infer of the schema plus onConnect?(item) / onDisconnect?(item)
  (each may return void or a Promise), onRetry?, className. Search text and
  the selected category are internal state: the consumer ships data, the
  block owns the toolbar.
- The component never fetches and never mutates items. The consumer's
  handler settles, then the consumer writes the new `connection` back.

Behavior
- Four first-class branches: loading (skeletons mirroring the toolbar + card
  anatomy, aria-hidden plus one sr-only role=status), empty ("No
  integrations available"), error (message + "Try again" only when onRetry
  exists), ready (toolbar + facets + card grid).
- Search is real and controlled. The raw input value renders instantly; a
  200 ms debounced copy drives filtering, so a server-backed catalog does
  not fire one query per keystroke. It matches name, description AND
  category, case-insensitively — people search "the calendar one" as often
  as they search a product name.
- Category chips are DERIVED from items in first-appearance order, so a chip
  that yields nothing cannot exist. Each chip carries the count computed
  over the search-filtered set: the number on the chip is exactly what you
  get after clicking it. Clicking the active chip clears it. If items change
  and the selected category disappears, selection falls back to "All"
  instead of stranding the user on an unselectable empty set.
- The result count is truthful and live: "Showing {visible} of {total}
  integrations" inside a role=status region, recomputed from the same array
  the grid renders.
- TWO DISTINCT EMPTY STATES, different copy and different exits:
  (a) the catalog itself is empty → "No integrations available";
  (b) filters excluded everything → "No integrations match your filters",
      naming the query and the active category, plus a "Clear filters"
      button that resets both. Collapsing these into one panel is a defect —
      the user cannot tell "nothing exists" from "you typed too much".
- Connection state machine per card: the rendered state is the server
  `connection` merged with a local in-flight attempt. Clicking calls the
  matching handler; the button switches to "Connecting…" / "Disconnecting…"
  with a spinner and aria-disabled while the promise is in flight.
  · The double-submit guard is a Set held in a ref and mutated
    synchronously inside the click handler — a guard that reads React state
    is one render behind, so a burst of clicks in the same tick walks
    straight through it.
  · A server-reported "connecting" also blocks new attempts (a handshake is
    already running elsewhere).
  · Use aria-disabled + an early return, NOT the native disabled attribute:
    native disabled blurs the button the moment it flips, dropping keyboard
    focus onto <body> right when the user needs the pending / failure text.
  · Invoke as new Promise(resolve => resolve(handler(item))) — a handler
    that throws SYNCHRONOUSLY escapes Promise.resolve(handler()) and would
    leave the button pending forever.
  · On rejection, put the Error's message on the card and swap the button to
    "Try again". Record the failure together with the connection value it
    was attempted from, so it self-invalidates as soon as the server state
    changes instead of keeping a stale claim on screen.
  · Announce the outcome once through a single sr-only role=status region,
    not one live region per card.
- Descriptions render in full: no line-clamp, no fixed card height, no
  overflow:hidden. A long description grows its card and the grid row aligns
  around it — silent truncation would hide exactly the sentence that
  explains what the integration does.
- If the handler for a card's next action is missing, the action button is
  not rendered at all. A button with nothing behind it is worse than no
  button.

Rendering & styling
- Semantic tokens only: bg-card cards, bg-muted icon tiles and neutral
  badges, bg-primary/10 + text-primary for "Connected", bg-destructive/10 +
  text-destructive for failures, text-muted-foreground for supporting copy.
  No hex, no brand colours.
- The grid is container-driven:
  grid-cols-[repeat(auto-fill,minmax(min(17rem,100%),1fr))] — the same block
  works in a settings pane, a doc column or a full page with no breakpoints.
  auto-fill (not auto-fit) keeps the last surviving card from stretching to
  a full row when a filter narrows the set to one.
- Status is never colour-only: every badge pairs a glyph (check / spinner /
  warning / plug) with a text label.
- Spinners carry motion-reduce:animate-none and nothing in the flow depends
  on animation. Focus-visible rings on every control; failure text is tied
  to its button with aria-describedby; cn() merges className.

Customization levers
- Icons: swap the ICONS map for simple-icons brand marks or <img> logos —
  the contract keeps a neutral key, so the data never changes.
- Density: drop the description for a compact directory, or add a
  "Connected as {account}" line under the badge; the card is a flex column
  with an mt-auto footer, nothing is position-locked.
- Facets: make the chips multi-select (aria-pressed already fits) or add a
  "Connected only" toggle by filtering the same `visible` array.
- Search: raise or lower the debounce, or hand `debouncedQuery` to a server
  query and feed the result back in through `items` — the toolbar does not
  care where filtering happens.
- Async model: have handlers return the updated item and write it back
  inside the block if you want it self-contained; keep the current
  consumer-owns-data shape when you need optimistic updates or cache
  invalidation.
- Column width: the 17rem minimum is the single number to tune for a denser
  or roomier wall.

Concepts

  • Filtered-empty ≠ empty catalog — "nothing matches your filters" and "there is nothing here" need different copy and different exits; one shared panel leaves the user unable to tell whether to clear the search or go install something.
  • Derived facets — the chip row is computed from the data in first-appearance order and each count is measured over the search-filtered set, so no chip can promise results it does not have, and none can go stale when the catalog changes.
  • Debounced query, instant field — the input stays fully controlled and repaints on every keystroke, while the filter (and, for a server-backed catalog, the request) only runs once typing pauses.
  • Server truth + local in-flight — the card merges the persisted connection with the attempt currently running in this browser, so the button reacts instantly without the component ever inventing data it does not own.
  • Synchronous double-submit guard — the in-flight set lives in a ref written inside the click handler; a guard based on React state is one render behind, and a burst of clicks in a single tick walks straight through it.
  • aria-disabled over disabled — a native disabled flip blurs the button mid-interaction and drops keyboard focus onto the document body, exactly when the user needs to read the pending or failure text beside it.

On This Page