# View Components

> The 14 components available to custom views

Every JSX element in a custom view must come from `"struere/view"`. The library exposes 14 components grouped into layout primitives, metrics, data, status, and state. Variant props (`tone`, `size`, `density`, `gap`) cover styling — `className` and `style` are not allowed.

The `Tone` type is shared across components: `'default' | 'info' | 'success' | 'warning' | 'danger'`.

## Layout

### Stack

Flex layout for arranging children in a row or column.

```tsx
<Stack direction="column" gap="md" align="start" justify="between">
  <KPI label="Scheduled" value={12} />
  <KPI label="Completed" value={48} />
</Stack>
```

| Prop | Type | Default |
|------|------|---------|
| `direction` | `'row' \| 'column'` | `'row'` |
| `gap` | `'xs' \| 'sm' \| 'md' \| 'lg'` | `'md'` |
| `align` | `'start' \| 'center' \| 'end' \| 'stretch'` | `'stretch'` |
| `justify` | `'start' \| 'center' \| 'end' \| 'between'` | `'start'` |
| `wrap` | `boolean` | `false` |

### Grid

CSS Grid with a fixed column count or a responsive map.

```tsx
<Grid columns={{ sm: 1, md: 2, lg: 4 }} gap="md">
  <KPI label="Scheduled" value={12} />
  <KPI label="Completed" value={48} />
  <KPI label="Students" value={120} />
  <KPI label="Teachers" value={8} />
</Grid>
```

| Prop | Type | Default |
|------|------|---------|
| `columns` | `number \| { sm?: number; md?: number; lg?: number }` | — |
| `gap` | `'sm' \| 'md' \| 'lg'` | `'md'` |

### Card

Section wrapper with an optional title, subtitle, and action slot.

```tsx
<Card title="Upcoming sessions" subtitle="Next 7 days" padding="md">
  <DataTable rows={rows} rowKey={(r) => r.id} columns={columns} />
</Card>
```

| Prop | Type | Default |
|------|------|---------|
| `title` | `string` | — |
| `subtitle` | `string` | — |
| `actions` | `ReactNode` | — |
| `padding` | `'sm' \| 'md' \| 'lg'` | `'md'` |

## Metrics

### KPI

A single metric tile with an optional hint and trend glyph.

```tsx
<KPI label="Scheduled" value={42} hint="Future and pending" trend="up" tone="info" />
```

| Prop | Type | Default |
|------|------|---------|
| `label` | `string` | — |
| `value` | `ReactNode` | — |
| `hint` | `string` | — |
| `trend` | `'up' \| 'down' \| 'flat'` | — |
| `tone` | `Tone` | `'default'` |

### ProgressRow

A single labeled progress bar.

```tsx
<ProgressRow label="Maria" value={7} max={10} hint="3 completed" tone="info" />
```

| Prop | Type | Default |
|------|------|---------|
| `label` | `ReactNode` | — |
| `value` | `number` | — |
| `max` | `number` | — |
| `hint` | `ReactNode` | — |
| `tone` | `Tone` | `'info'` |

### ProgressList

Ranked list of `ProgressRow`-shaped items.

```tsx
<ProgressList
  items={teachers.map((t) => ({
    id: t.id,
    label: t.name,
    value: t.scheduled,
    max: maxLoad,
    hint: `${t.completed} completed`,
    tone: "info",
  }))}
/>
```

Each item: `{ id: string; label: ReactNode; value: number; max: number; hint?: ReactNode; tone?: Tone }`.

## Data

### DataTable

Generic tabular display. `rows` and `columns` are generic over a row type.

```tsx
<DataTable<SessionRow>
  rows={sessions}
  rowKey={(row) => row.id}
  emptyState="No upcoming sessions."
  density="cozy"
  columns={[
    { key: "time", header: "Time", cell: (row) => <DateTime value={row.scheduledAt} format="short" /> },
    { key: "subject", header: "Subject", cell: (row) => row.subject },
    { key: "status", header: "Status", cell: (row) => <StatusBadge status={row.status} /> },
  ]}
/>
```

| Prop | Type | Default |
|------|------|---------|
| `rows` | `T[]` | — |
| `columns` | `Array<DataTableColumn<T>>` | — |
| `rowKey` | `(row: T) => string` | — |
| `emptyState` | `ReactNode` | — |
| `loading` | `boolean` | `false` |
| `density` | `'compact' \| 'cozy'` | `'cozy'` |

Each column: `{ key: string; header: string; cell?: (row: T) => ReactNode; align?: 'start' \| 'end'; width?: 'sm' \| 'md' \| 'lg' \| 'auto' }`.

## Status

### StatusBadge

A status pill with a default tone map covering the common entity statuses. Pass a custom `map` to override.

```tsx
<StatusBadge status="completed" />
<StatusBadge
  status="onboarding"
  map={{ onboarding: { label: "Onboarding", tone: "info" } }}
/>
```

| Prop | Type | Default |
|------|------|---------|
| `status` | `string` | — |
| `map` | `Record<string, { label: string; tone: Tone }>` | — |
| `tone` | `Tone` | — |

The default map covers `scheduled`, `completed`, `pending`, `cancelled`, and other common values.

### Chip

A single tag.

```tsx
<Chip label="VIP" tone="info" size="sm" />
```

| Prop | Type | Default |
|------|------|---------|
| `label` | `string` | — |
| `tone` | `Tone` | `'default'` |
| `size` | `'sm' \| 'md'` | `'md'` |

### ChipCluster

A wrapped row of chips. Extras past `max` roll into a `"+N more"` chip.

```tsx
<ChipCluster
  chips={tags.map((t) => ({ id: t, label: t, tone: "default" }))}
  max={5}
/>
```

Each item: `{ id: string; label: string; tone?: Tone; size?: 'sm' \| 'md' }`.

## Timestamps

### DateTime

Formatted absolute timestamp.

```tsx
<DateTime value={row.scheduledAt} format="short" />
```

| Prop | Type | Default |
|------|------|---------|
| `value` | `string \| number \| Date` | — |
| `format` | `'short' \| 'medium' \| 'long' \| 'time'` | `'medium'` |

### RelativeTime

Auto-updating relative timestamp. Use this instead of `setInterval` — timers are not available inside a view.

```tsx
<RelativeTime value={row.createdAt} updateInterval={60000} />
```

| Prop | Type | Default |
|------|------|---------|
| `value` | `string \| number \| Date` | — |
| `updateInterval` | `number` (ms) | `60000` |

## State

### EmptyState

Placeholder for empty data, with an optional action.

```tsx
<EmptyState
  title="No sessions yet"
  description="Sessions will appear once a teacher schedules one."
/>
```

| Prop | Type |
|------|------|
| `title` | `string` |
| `description` | `string` |
| `action` | `{ label: string; onClick: () => void }` |

### ErrorState

Display for caught errors, with an optional retry handler.

```tsx
<ErrorState title="Could not load sessions" error={query.error} onRetry={() => query.refetch()} />
```

| Prop | Type | Default |
|------|------|---------|
| `title` | `string` | `'Something went wrong'` |
| `error` | `Error \| string` | — |
| `onRetry` | `() => void` | — |

## Tone Semantics

| Tone | Meaning |
|------|---------|
| `default` | Neutral, no emphasis |
| `info` | Informational — scheduled, pending, in progress |
| `success` | Positive outcomes — completed, paid, active |
| `warning` | Needs attention — overdue, low credit |
| `danger` | Failure or destructive state — failed, cancelled |

`StatusBadge`'s default map already maps the common status strings to tones, so most of the time you only need to pass a custom tone for non-standard statuses.
