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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/integration-grid.jsonPrompt
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
connectionwith 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
disabledflip 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.
Dashboard Shell
An app shell with a pinned top bar, a collapsible sidebar that becomes a modal drawer on mobile, and a main region that is the page's only scroll container.
Metric Overview
A dashboard KPI row — one shared range switcher over comparable cards, deltas coloured by each metric's polarity, and a sparkline per card.