---
name: aircall-blocks/migrate-dashboard/list
description: >
  Migrate @dashboard/library List, ListItem, ListPaginated, and ListItemActions to
  @aircall/ds Item primitives (ItemGroup, Item, ItemMedia, ItemContent, ItemActions,
  ItemTitle, ItemDescription). Load when a file imports List, ListItem, ListPaginated,
  or ListItemActions from @dashboard/library.
type: sub-skill
library: aircall-blocks
requires:
  - aircall-blocks/setup
  - aircall-blocks/migrate-dashboard
sources:
  - "aircall/hydra:packages/ds/src/index.ts"
---

This skill builds on aircall-blocks/migrate-dashboard.

## 1. Component mapping

| @dashboard/library | @aircall/ds |
| --- | --- |
| `List` (container) | `ItemGroup` |
| `List` `hasScroll` prop | `ScrollArea` wrapping `ItemGroup` |
| `List` `stickyFooter` prop | Sibling `div` below `ItemGroup`; no DS equivalent |
| `List` `isLoadingMoreItems` + `customLoader` | Inline `Spinner` below `ItemGroup`; no DS equivalent |
| `List` `overLoading` | Overlay `Spinner` positioned absolutely over `ItemGroup` |
| `List` `renderHeader` | Sibling element above `ItemGroup` |
| `ListItem` (row) | `Item` |
| `ListItem` `avatar` prop | `ItemMedia` with `variant="image"` inside `Item` |
| `ListItem` `primaryText` prop | `ItemTitle` inside `ItemContent` inside `Item` |
| `ListItem` `secondaryText` prop | `ItemDescription` inside `ItemContent` inside `Item` |
| `ListItem` `dropdownOptions` prop | `DropdownMenu` with `DropdownMenuItem` entries inside `ItemActions` inside `Item` |
| `ListItem` `banner` prop | Sibling element below `Item` (no direct DS slot) |
| `ListItem` arbitrary `FlexProps` (px, py, h…) | `className` on `Item`; use Tailwind utilities |
| `ListItemActions` (wrapper with action menu) | `Item` + `ItemActions` + `DropdownMenu` from `@aircall/ds` |
| `ListPaginated` | `ItemGroup` + `Pagination` (and its parts) from `@aircall/ds` |

`@dashboard/library` `List` wrapped items in `<ul>/<li>` and auto-added dividers via CSS.
`ItemGroup` uses `role="list"` and a `stackedItems` prop — set `stackedItems` to replicate the
no-gap, border-between-items visual style.

`ListItem` accepted raw `FlexProps` from `@aircall/tractor` (spacing tokens, color tokens). `Item`
extends `React.ComponentProps<'div'>` — use `className` with Tailwind utilities instead.

## 2. Imports

```tsx
// DS primitives — item / list family
import {
  Item,
  ItemActions,
  ItemContent,
  ItemDescription,
  ItemGroup,
  ItemMedia,
  ItemTitle
} from '@aircall/ds';

// DS pagination (for ListPaginated)
import {
  Pagination,
  PaginationContent,
  PaginationItem,
  PaginationLink,
  PaginationNext,
  PaginationPrevious
} from '@aircall/ds';

// DS dropdown (for dropdownOptions)
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger
} from '@aircall/ds';
```

No `@aircall/blocks` imports are needed for this migration — `Item*` and `ItemGroup` live entirely
in `@aircall/ds`.

## 3. Before / After

### 3a. Basic List with avatar + text items

**Before (`@dashboard/library`):**
```tsx
import { List, ListItem } from '@dashboard/library';

function UserList({ users }: { users: { id: string; name: string; role: string; avatar: React.ReactElement }[] }) {
  return (
    <List>
      {users.map((u) => (
        <ListItem
          key={u.id}
          avatar={u.avatar}
          primaryText={u.name}
          secondaryText={u.role}
        />
      ))}
    </List>
  );
}
```

**After (`@aircall/ds`):**
```tsx
import { Item, ItemContent, ItemDescription, ItemGroup, ItemMedia, ItemTitle } from '@aircall/ds';

function UserList({ users }: { users: { id: string; name: string; role: string; avatar: React.ReactElement }[] }) {
  return (
    <ItemGroup stackedItems>
      {users.map((u) => (
        <Item key={u.id} variant="outline">
          <ItemMedia variant="image">{u.avatar}</ItemMedia>
          <ItemContent>
            <ItemTitle>{u.name}</ItemTitle>
            <ItemDescription>{u.role}</ItemDescription>
          </ItemContent>
        </Item>
      ))}
    </ItemGroup>
  );
}
```

Key changes:
- `List` → `ItemGroup` with `stackedItems` to preserve the stacked-row layout with
  collapsed borders between items.
- `ListItem` → `Item` with `variant="outline"` (gives each row a visible border).
- `avatar` → `ItemMedia variant="image"` as first child of `Item`.
- `primaryText` → `ItemTitle` inside `ItemContent`.
- `secondaryText` → `ItemDescription` inside `ItemContent`.
- Drop all `FlexProps` token spreads; use `className` with Tailwind utilities instead.

### 3b. ListItem with custom children and dropdown actions

**Before (`@dashboard/library`):**
```tsx
import { List, ListItem } from '@dashboard/library';

function IntegrationList({ items }: { items: { id: string; name: string }[] }) {
  return (
    <List>
      {items.map((item) => (
        <ListItem
          key={item.id}
          primaryText={item.name}
          dropdownOptions={[
            { title: 'Edit', callback: () => onEdit(item.id) },
            { title: 'Delete', callback: () => onDelete(item.id) }
          ]}
        />
      ))}
    </List>
  );
}
```

**After (`@aircall/ds`):**
```tsx
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
  Item,
  ItemActions,
  ItemContent,
  ItemGroup,
  ItemTitle
} from '@aircall/ds';
import { Button } from '@aircall/ds';
import { EllipsisVertical } from '@aircall/react-icons';

function IntegrationList({ items }: { items: { id: string; name: string }[] }) {
  return (
    <ItemGroup stackedItems>
      {items.map((item) => (
        <Item key={item.id} variant="outline">
          <ItemContent>
            <ItemTitle>{item.name}</ItemTitle>
          </ItemContent>
          <ItemActions>
            <DropdownMenu>
              <DropdownMenuTrigger asChild>
                <Button variant="ghost" size="icon" aria-label="More actions">
                  <EllipsisVertical />
                </Button>
              </DropdownMenuTrigger>
              <DropdownMenuContent align="end">
                <DropdownMenuItem onClick={() => onEdit(item.id)}>Edit</DropdownMenuItem>
                <DropdownMenuItem onClick={() => onDelete(item.id)}>Delete</DropdownMenuItem>
              </DropdownMenuContent>
            </DropdownMenu>
          </ItemActions>
        </Item>
      ))}
    </ItemGroup>
  );
}
```

Key changes:
- `dropdownOptions` prop → explicit `DropdownMenu` composition inside `ItemActions`.
- `ItemActions` becomes the last child of `Item`; the flex layout positions it to the right
  automatically.
- Icons: import from `@aircall/react-icons`, not `lucide-react` directly.

### 3c. ListItemActions (card-style item with action menu)

**Before (`@dashboard/library`):**
```tsx
import { ListItemActions } from '@dashboard/library';

function PhoneNumberRow({ number, onEdit, onDelete }: { number: string; onEdit: () => void; onDelete: () => void }) {
  return (
    <ListItemActions
      actions={[
        { name: 'Edit', onClick: onEdit },
        { name: 'Delete', onClick: onDelete }
      ]}
    >
      <span>{number}</span>
    </ListItemActions>
  );
}
```

**After (`@aircall/ds`):**
```tsx
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
  Item,
  ItemActions,
  ItemContent,
  ItemTitle
} from '@aircall/ds';
import { Button } from '@aircall/ds';
import { EllipsisVertical } from '@aircall/react-icons';

function PhoneNumberRow({ number, onEdit, onDelete }: { number: string; onEdit: () => void; onDelete: () => void }) {
  return (
    <Item variant="outline">
      <ItemContent>
        <ItemTitle>{number}</ItemTitle>
      </ItemContent>
      <ItemActions>
        <DropdownMenu>
          <DropdownMenuTrigger asChild>
            <Button variant="ghost" size="icon" aria-label="More actions">
              <EllipsisVertical />
            </Button>
          </DropdownMenuTrigger>
          <DropdownMenuContent align="end">
            <DropdownMenuItem onClick={onEdit}>Edit</DropdownMenuItem>
            <DropdownMenuItem onClick={onDelete}>Delete</DropdownMenuItem>
          </DropdownMenuContent>
        </DropdownMenu>
      </ItemActions>
    </Item>
  );
}
```

Key changes:
- `ListItemActions` `actions` prop → `DropdownMenu` composition inside `ItemActions`.
- `ListItemActions` `children` → `ItemContent` / `ItemTitle` inside `Item`.
- The absolute-positioned action menu is replaced by `ItemActions` flex layout.

### 3d. ListPaginated

**Before (`@dashboard/library`):**
```tsx
import { ListPaginated, ListItem } from '@dashboard/library';

function PaginatedUsers({ users, page, onPageChange, onPageSizeChange, total, pageSize }) {
  return (
    <ListPaginated
      activePage={page}
      onPageChange={onPageChange}
      onPageSizeChange={onPageSizeChange}
      pageSize={pageSize}
      recordsTotalCount={total}
    >
      {users.map((u) => (
        <ListItem key={u.id} primaryText={u.name} />
      ))}
    </ListPaginated>
  );
}
```

**After (`@aircall/ds`):**
```tsx
import {
  Item,
  ItemContent,
  ItemGroup,
  ItemTitle,
  Pagination,
  PaginationContent,
  PaginationItem,
  PaginationLink,
  PaginationNext,
  PaginationPrevious
} from '@aircall/ds';

function PaginatedUsers({ users, page, onPageChange, total, pageSize }) {
  const totalPages = Math.ceil(total / pageSize);

  return (
    <div className="flex flex-col gap-3">
      <ItemGroup stackedItems>
        {users.map((u) => (
          <Item key={u.id} variant="outline">
            <ItemContent>
              <ItemTitle>{u.name}</ItemTitle>
            </ItemContent>
          </Item>
        ))}
      </ItemGroup>
      <Pagination>
        <PaginationContent>
          <PaginationItem>
            <PaginationPrevious
              onClick={() => onPageChange(page - 1)}
              aria-disabled={page <= 1}
            />
          </PaginationItem>
          {Array.from({ length: totalPages }, (_, i) => (
            <PaginationItem key={i + 1}>
              <PaginationLink isActive={page === i + 1} onClick={() => onPageChange(i + 1)}>
                {i + 1}
              </PaginationLink>
            </PaginationItem>
          ))}
          <PaginationItem>
            <PaginationNext
              onClick={() => onPageChange(page + 1)}
              aria-disabled={page >= totalPages}
            />
          </PaginationItem>
        </PaginationContent>
      </Pagination>
    </div>
  );
}
```

Key changes:
- `ListPaginated` has no DS equivalent — decompose into `ItemGroup` + `Pagination` parts.
- `@aircall/ds` `Pagination` is a composable primitive; build page links explicitly.
- `onPageSizeChange` has no DS equivalent — implement with a `Select` from `@aircall/ds` if needed.

---

## 4. Common mistakes

### Mistake 1 — Missing stackedItems on ItemGroup

```tsx
// ❌ Wrong — gaps between items, no stacked border collapse
<ItemGroup>
  <Item variant="outline">…</Item>
  <Item variant="outline">…</Item>
</ItemGroup>

// ✅ Correct — stacked rows with collapsed borders (matches List visual style)
<ItemGroup stackedItems>
  <Item variant="outline">…</Item>
  <Item variant="outline">…</Item>
</ItemGroup>
```

Without `stackedItems`, `ItemGroup` applies `gap-4` between items (card-grid style). With
`stackedItems`, it collapses borders between adjacent `Item` rows and removes gap, replicating
the `List` border-separated row appearance.

Source: `packages/ds/src/components/item.tsx`

### Mistake 2 — Spreading tractor FlexProps onto Item

```tsx
// ❌ Wrong — tractor token props (px, py, h, color) do not exist on Item
<Item px="s" py="xxs" h="xl" color="neutral-600">
  <ItemContent>…</ItemContent>
</Item>

// ✅ Correct — use className with Tailwind utilities
<Item className="px-3 py-1.5 min-h-10">
  <ItemContent>…</ItemContent>
</Item>
```

`ListItem` accepted arbitrary `FlexProps` from `@aircall/tractor` (design tokens). `Item`
extends `React.ComponentProps<'div'>` — only standard HTML attributes and `className` are
accepted. Passing token strings results in React warnings for unknown DOM attributes.

Source: `packages/ds/src/components/item.tsx`

### Mistake 3 — Placing ItemActions outside Item

```tsx
// ❌ Wrong — ItemActions outside Item has no flex parent to pin it right
<ItemGroup stackedItems>
  <Item variant="outline">
    <ItemContent><ItemTitle>Name</ItemTitle></ItemContent>
  </Item>
  <ItemActions>
    <Button>Edit</Button>
  </ItemActions>
</ItemGroup>

// ✅ Correct — ItemActions is the last child inside Item
<ItemGroup stackedItems>
  <Item variant="outline">
    <ItemContent><ItemTitle>Name</ItemTitle></ItemContent>
    <ItemActions>
      <Button>Edit</Button>
    </ItemActions>
  </Item>
</ItemGroup>
```

`Item` is a flex row (`flex items-center`). `ItemActions` carries `data-slot="item-actions"` and
relies on being a flex child to align right. Outside `Item`, it renders in document flow with no
flex context.

Source: `packages/ds/src/components/item.tsx`

### Mistake 4 — Using ListItemActions children directly as ItemTitle text

```tsx
// ❌ Wrong — bare string child of Item is not styled as a title
<Item variant="outline">
  {number}
  <ItemActions>…</ItemActions>
</Item>

// ✅ Correct — wrap in ItemContent > ItemTitle for correct typography and layout slot
<Item variant="outline">
  <ItemContent>
    <ItemTitle>{number}</ItemTitle>
  </ItemContent>
  <ItemActions>…</ItemActions>
</Item>
```

`ListItemActions` accepted `children` as a raw React node and rendered it freely. `Item` is
a flex row — raw text nodes do not receive the `ItemTitle` typography or the `flex-1` grow
behaviour that `ItemContent` provides. Without `ItemContent`, `ItemActions` may not push to
the right end of the row.

Source: `packages/ds/src/components/item.tsx`

### Mistake 5 — Importing icons from lucide-react directly

```tsx
// ❌ Wrong — imports lucide directly, bypassing the Aircall icon layer
import { MoreVertical } from 'lucide-react';

// ✅ Correct — route all icons through @aircall/react-icons
import { EllipsisVertical } from '@aircall/react-icons';
```

`@aircall/react-icons` re-exports all lucide icons plus Aircall custom icons. Direct lucide
imports bypass the single source of truth and prevent Aircall icon overrides.

Source: `packages/ds/src/components/item.tsx`
