> ## Documentation Index
> Fetch the complete documentation index at: https://sdk.ekanos.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Widgets

> Dashboard cards that render through the Widget compound API with automatic data state handling.

Widgets are client components that render on the Ekanos dashboard. Use the `Widget.*` compound API for consistent styling and behavior.

## Basic widget

```tsx theme={null}
'use client';

import { use } from 'react';
import type { IntegrationComponentProps } from '@ekanos/sdk';
import { Widget, WidgetContext } from '@ekanos/sdk/components';
import { useCurrentWeather } from './hooks/use-current-weather';

export function CurrentWeatherWidget({ accountId }: IntegrationComponentProps) {
  const ctx = use(WidgetContext);
  // Only fetch while active
  const query = useCurrentWeather(accountId, ctx?.state.state === 'active');

  return (
    <Widget.DataState loading={query.isLoading} error={query.isError}>
      <Widget.Card>
        <Widget.Header title="Current conditions" />
        <Widget.Loading>Fetching weather...</Widget.Loading>
        <Widget.Error>Could not load weather data.</Widget.Error>
        <Widget.Active>
          <Widget.Body>
            {query.data && (
              <div>
                <p className="text-2xl">{query.data.temperature}°</p>
                <p className="text-muted-foreground">{query.data.condition}</p>
              </div>
            )}
          </Widget.Body>
        </Widget.Active>
      </Widget.Card>
    </Widget.DataState>
  );
}
```

## Widget declaration

```ts theme={null}
components: {
  widgets: [
    {
      id: 'acme-payments-summary',      // kebab-case, globally unique — prefix with slug
      name: 'Acme Payments',            // Display name
      component: AcmePaymentsSummaryWidget,
      widgetState: 'active',            // 'active' | 'inactive' | 'disabled'
      isCollapsible: true,
      isPinnable: false,
      aiFooterEnabled: true,            // "Ask about this" footer
      layouts: {
        lg: { x: 0, y: 0, w: 4, h: 8 },
        md: { x: 0, y: 0, w: 4, h: 8 },
        sm: { x: 0, y: 0, w: 4, h: 8 },
      },
    },
  ],
}
```

* **`id`** — globally unique; prefix with your slug to avoid collisions
* **`widgetState`** — required: `'active'`, `'inactive'`, or `'disabled'`. `id`, `name`, `component`, and `widgetState` are the only required fields
* **`layouts`** — seeds the default dashboard placement per breakpoint (`lg`, `md`, `sm`); `gridSize` and `gridPosition` are the older equivalents and still accepted
* **`isPinnable`** — reaches `WidgetContext` and adds "Pin to board" / "Unpin from board" to the `Widget.Header` ⋯ menu

Fields the platform populates at runtime (`productId`, `widgetConfigId`, `workspaceId`, `collapsed`, `isPinned`, `health`, `integrationMetadata`) are rejected in a declaration.

## Widget API

### Widget.DataState

Overrides the render state with your widget's own data state — only while the integration itself is `active`, so `inactive`/`disabled` still win. An `inactive` prop forces the inactive state (for example, activated but the upstream connection is missing):

```tsx theme={null}
<Widget.DataState loading={isLoading} error={isError}>
  <Widget.Card>
    <Widget.Loading>…</Widget.Loading>
    <Widget.Error>…</Widget.Error>
    <Widget.Active>…</Widget.Active>
  </Widget.Card>
</Widget.DataState>
```

Three optional props opt the widget into the shared refresh and freshness chrome:

| Prop | Type | Adds |
| - | - | - |
| `onRefresh` | `() => void` | A "Refresh" item in the header ⋯ menu and a "Try again" button on the default `Widget.Error` view |
| `refreshing` | `boolean` | Marks a reload in flight, so the Refresh item is disabled and the footer shows it |
| `updatedAt` | epoch ms, ISO string, or `Date` | An "Updated 3 minutes ago" footer on `Widget.Card`. `0` means never. |

With TanStack Query:

```tsx theme={null}
<Widget.DataState
  loading={query.isLoading}
  error={query.isError}
  onRefresh={() => void query.refetch()}
  refreshing={query.isFetching}
  updatedAt={query.dataUpdatedAt}
>
```

Pass none of them and the widget behaves as before. The host cannot see your queries, so each affordance appears only when you supply what it needs.

### Widget.Card

The card container (widget elevation shadow, no border):

```tsx theme={null}
<Widget.Card>
  <Widget.Header title="Title" subtitle="Optional" />
  <Widget.Active>…</Widget.Active>
</Widget.Card>
```

### Widget.Header

Inset header. `title`, `subtitle`, and `description` (shown as a tooltip) override the provider's values; `children` render on the right. It renders nothing when there is no title from either source.

```tsx theme={null}
<Widget.Header title="My Widget" subtitle="Optional subtitle">
  <button>Action</button>
</Widget.Header>
```

Every `Widget.Header` also renders a ⋯ menu with the actions that apply. With none of them applicable, the menu does not render:

* **Open in workspace** — when the host sets `WidgetMeta.openHref`
* **Refresh** — when the widget reports a refresh through `Widget.DataState`'s `onRefresh`
* **Pin to board** / **Unpin from board** — when the widget declares `isPinnable`

Add your own items with the `menu` prop and `Widget.MenuItem`. They sit at the top of the menu. Use them for occasional actions such as "Open in QuickBooks", and keep `children` for controls people use on every visit, like filters, so the header does not carry a row of buttons plus the menu.

```tsx theme={null}
<Widget.Header
  title="Invoices"
  menu={
    <>
      <Widget.MenuItem href="https://app.example.com/invoices" external>
        Open in Example
      </Widget.MenuItem>
      <Widget.MenuItem onSelect={exportCsv}>Export CSV</Widget.MenuItem>
    </>
  }
>
  <MonthFilter />
</Widget.Header>
```

`Widget.MenuItem` takes `href` (a link; `external` opens it in a new tab), `onSelect`, `disabled`, and `icon` (a Font Awesome class such as `fa-light fa-envelope`).

### Widget.Active

Content area. By default, `p-0` for full-bleed content. Wrap text in `Widget.Body` for standard padding:

```tsx theme={null}
<Widget.Active>
  {/* Full-bleed content (chart, table) */}
  <ChartComponent />
</Widget.Active>

<Widget.Active>
  {/* Padded content */}
  <Widget.Body>
    <p>Regular text with inset padding</p>
  </Widget.Body>
</Widget.Active>
```

### Widget.Body

Padded flex-column container (`px-5 pt-4 pb-5`):

```tsx theme={null}
<Widget.Active>
  <Widget.Body>
    <div>Rows and text here stay inset</div>
  </Widget.Body>
</Widget.Active>
```

### Other parts

`Widget.Inactive` and `Widget.Disabled` render for those states (with default content if you pass no children), `Widget.Footer` renders only while active, `Widget.Actions` is a muted footer bar, `Widget.AskFooter` renders the "Ask about this" bar, and `Widget.HealthFooter` shows a stale-data notice when the integration is `degraded`, including when it last synced (from `WidgetHealth.lastSuccessAt`) when that is known.

Every `Widget.*` part must render inside a widget provider. Outside one it throws `Widget.* compound components must be rendered inside a widget provider.` — the host supplies one on the dashboard; elsewhere use `WidgetPreviewProvider`.

### Recovering from errors

The error boundary inside `Widget.Active` catches a crashing widget and offers a "Try again" button that re-mounts it (and reloads its data when the widget reports a refresh), instead of telling the user to reload the page. The default `Widget.Error` view shows the same "Try again" button when you pass `onRefresh` to `Widget.DataState`.

## WidgetContext

Access widget metadata and state:

```tsx theme={null}
const ctx = use(WidgetContext);

// ctx.state: { state: 'active' | 'disabled' | 'loading' | 'error' | 'inactive',
//              collapsed, pinned, pendingCollapse, pendingPin }
if (ctx?.state.state === 'active') {
  // Fetch data
}

// Control widget state
ctx?.actions.toggleCollapsed();
ctx?.actions.togglePinned();
```

`WidgetContextValue` also has an optional `data` field (`WidgetData`: `updatedAt`, `refreshing`, `refresh`), filled from `Widget.DataState`'s props, and `ctx.meta.openHref`. Both types are exported from `@ekanos/sdk/components`. Custom providers that build a `WidgetContextValue` keep compiling unchanged.

Read `ctx?.state.state` **outside** `Widget.DataState` to gate data fetching on the integration being active. `ctx.meta` carries `widgetId`, `title`, `isCollapsible`, `isPinnable`, `aiFooterEnabled`, and related display fields.

## Reading storage

Widgets fetch `clientReadable` storage keys:

```tsx theme={null}
import { fetchIntegrationStorage } from '@ekanos/sdk/hooks';

const values = await fetchIntegrationStorage('acme-payments', {
  accountId,
  keys: ['settings/location'],
});

const location = values['settings/location']; // unknown — validate with your schema
```

Keys without `clientReadable: true` are simply absent from the result, as are keys never written. See [Storage](/sdk/surfaces/storage).

## Styling

Use semantic Tailwind classes (Ekanos design tokens):

```tsx theme={null}
<div className="bg-card border-border text-foreground">
  <h3 className="text-lg font-semibold">Title</h3>
  <p className="text-sm text-muted-foreground">Subtitle</p>
</div>
```

Avoid `bg-white`, `text-black`, or hex colors. See [Styling](/sdk/styling) for loading the token layer outside Ekanos.

## Common patterns

### Chart widget

```tsx theme={null}
export function PaymentChartWidget({ accountId }: IntegrationComponentProps) {
  const query = usePaymentStats(accountId);

  return (
    <Widget.DataState loading={query.isLoading} error={query.isError}>
      <Widget.Card>
        <Widget.Header title="Payment Volume" />
        <Widget.Loading />
        <Widget.Error />
        <Widget.Active>
          {/* Charts are full-bleed (chart components from your charting library, e.g. recharts) */}
          <ResponsiveContainer width="100%" height={300}>
            <BarChart data={query.data}>
              <CartesianGrid />
              <XAxis />
              <YAxis />
              <Bar dataKey="amount" />
            </BarChart>
          </ResponsiveContainer>
        </Widget.Active>
      </Widget.Card>
    </Widget.DataState>
  );
}
```

### Data table widget

```tsx theme={null}
export function RecentPaymentsWidget({ accountId }: IntegrationComponentProps) {
  const query = useRecentPayments(accountId);

  return (
    <Widget.DataState loading={query.isLoading} error={query.isError}>
      <Widget.Card>
        <Widget.Header title="Recent Payments" />
        <Widget.Loading />
        <Widget.Error />
        <Widget.Active>
          {/* Tables are full-bleed */}
          <table className="w-full">
            <thead>
              <tr className="border-b border-border">
                <th className="px-5 py-2 text-left">ID</th>
                <th className="px-5 py-2 text-right">Amount</th>
              </tr>
            </thead>
            <tbody>
              {query.data?.map((row) => (
                <tr key={row.id} className="border-b border-border">
                  <td className="px-5 py-2">{row.id}</td>
                  <td className="px-5 py-2 text-right">${row.amount}</td>
                </tr>
              ))}
            </tbody>
          </table>
        </Widget.Active>
      </Widget.Card>
    </Widget.DataState>
  );
}
```

## Testing widgets

Wrap in `WidgetPreviewProvider` to render without the host. It fills the same `WidgetContext` with local state and no persistence; `state` defaults to `'active'`:

```tsx theme={null}
import { WidgetPreviewProvider } from '@ekanos/sdk/components';

render(
  <WidgetPreviewProvider widgetId="acme-payments-summary" title="Payments">
    <CurrentWeatherWidget accountId="acc_123" productSlug="acme-payments" />
  </WidgetPreviewProvider>,
);
```

## Troubleshooting

### Widget ID collision

Widget IDs are globally unique. Prefix with your slug: `acme-payments-summary`, not just `summary`.

### Widget always loading

Check:

* The `loading` you pass to `Widget.DataState` eventually becomes `false`
* Your query's enabled flag reads the integration state from `WidgetContext` outside `Widget.DataState`
* `fetchIntegrationStorage()` keys have `clientReadable: true` (absent keys come back as missing, not as an error)

### Styling looks wrong

Outside Ekanos, load the `@ekanos/ui` token layer — see [Styling](/sdk/styling). Use semantic classes (`bg-card`, `text-foreground`), not hardcoded colors.

## Next steps

* [Marketplace tile](/sdk/surfaces/marketplace-tile) — card for the integration marketplace
* [Activation form](/sdk/surfaces/activation-form) — connect form
* [Testing](/sdk/testing) — test widgets


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.