# StackedList

Package: @nannier/canvas
Version: 4.1.0
Status: source-snapshot (not proof of an npm release)
Source SHA-256: 63292636c7ff2f224185aef7c96ac78124f9170a170b90ee11718addfa280643

Source: `src/molecules/stacked-lists/stacked-lists.md`
Human reference: https://canvas.nannier.com/components/stacked-lists

## Public imports

```tsx
import { StackedList } from "@nannier/canvas";
```

The following examples are source excerpts. Supply application data and handlers; snippets using `tokens` read `const { tokens } = useTheme()` inside a component. Import React hooks from React. Intentional “Don't” examples illustrate unsupported patterns.

Vertical lists with avatar, two-line items, and trailing metadata. Used for
contacts, activity feeds, and data previews.

Rows can carry a per-item `trailing` slot for an inline control, and
`reorderable` adds a leading drag grip per row (keyboard- and
screen-reader-operable) that reports each drop through `onReorder` while the
order stays controlled by your `items` array.

The rows are a list, as in Tailwind UI, and each row is one of its items: a
screen reader announces the list with its number of rows and moves through it
row by row. A clickable row's button sits inside its item. The header `title`
names the list; a list without a title takes its name from `label`.

A `virtualized` list with a bounded height scrolls its rows in a list of its
own. On the web, while those rows overflow, that list is a keyboard tab stop
(after the header's action, and before the rows' own buttons when they have
any), the keyboard can scroll it, and while it has keyboard focus the list's
card, or a plain list's frame, draws the theme's focus ring. Only the rows near
its viewport are mounted, and each says where it sits in the whole list, so a
screen reader still counts every row. Give a windowed list a `title` or a
`label`: focused, a list with no name is named in Chromium from the text of
every row it has rendered, so the kit warns in development when it has neither.

## Usage

```tsx
<StackedList
  items={[
    { name: "Rachel Chen", detail: "rachel.chen@example.com", meta: "admin" },
    { name: "Ada Lovelace", detail: "ada@example.com", meta: "editor" },
    { name: "Kevin Turner", detail: "kevin@example.com", meta: "viewer" }
  ]}
/>
```

## Variants

### Status tones

```tsx
<StackedList
  items={[
    { name: "Kratos", detail: "Identity", badge: "Healthy", success: true },
    { name: "Hydra", detail: "OAuth2", badge: "Degraded", warning: true },
    { name: "Postgres", detail: "Database", badge: "Down", error: true },
  ]}
/>
```

### Clickable

```tsx
<StackedList
  items={[
    { name: "Rachel Chen", detail: "rachel.chen@example.com", meta: "2h ago" },
    { name: "Ada Lovelace", detail: "ada@example.com", meta: "5h ago" },
    { name: "Kevin Turner", detail: "kevin@example.com", meta: "1d ago" }
  ]}
  clickable
/>
```

### Card

```tsx
<StackedList
  card
  title="Team members"
  items={[
    { name: "Rachel Chen", detail: "Engineering Lead" },
    { name: "Ada Lovelace", detail: "Staff Engineer" }
  ]}
/>
```

### Custom header action

```tsx
<StackedList
  card
  title="Team members"
  action={<Button ghost small>Invite</Button>}
  items={[
    { name: "Rachel Chen", detail: "Engineering Lead" },
    { name: "Ada Lovelace", detail: "Staff Engineer" }
  ]}
/>
```

### Reorderable

Each row gains a leading grip: drag it, or focus it and press Space to grab, the arrow keys to move, Space to drop, Escape to cancel. The order stays controlled: a drop only reports the move (`fromIndex`/`toIndex` plus the new `afterId`/`beforeId` neighbors), and the consumer applies it to its own array.

```tsx
<Stateful initial={[
  { id: "rachel", name: "Rachel Chen", detail: "rachel.chen@example.com" },
  { id: "ada", name: "Ada Lovelace", detail: "ada@example.com" },
  { id: "kevin", name: "Kevin Turner", detail: "kevin@example.com" },
]}>
  {(people, setPeople) => (
    <StackedList
      reorderable
      items={people}
      onReorder={({ fromIndex, toIndex }) => {
        const next = [...people];
        next.splice(toIndex, 0, ...next.splice(fromIndex, 1));
        setPeople(next);
      }}
    />
  )}
</Stateful>
```

### Inline trailing control

A per-item `trailing` slot renders right-aligned before the badge/meta cluster, for an inline editor or affordance. In the `clickable` variant a row carrying the slot moves its press target to the content region, so the control never nests inside the row pressable.

```tsx
<StackedList
  items={[
    { name: "Rachel Chen", detail: "rachel.chen@example.com", trailing: <Chip selectable defaultSelected>Owner</Chip> },
    { name: "Ada Lovelace", detail: "ada@example.com", trailing: <Chip selectable>Owner</Chip>, meta: "editor" },
  ]}
/>
```

## Do & Don't

### Two-line with avatar

**Do** — Primary line bold; secondary line smaller and muted, truncated so long values never wrap.

```tsx
<StackedList items={[
    { name: "Rachel Chen", detail: "rachel.chen@example.com", initials: "RC" }
  ]} />
```

**Don't** — Equal weight on both lines flattens the hierarchy; the email competes with the name.

```tsx
<View style={{ borderRadius: 8, borderWidth: 1, borderColor: tokens.border, backgroundColor: tokens.card, overflow: "hidden", boxShadow: "0px 16px 32px -22px rgba(121, 100, 214, 0.22)", width: "100%", maxWidth: 560 }}>
  <View style={{ flexDirection: "row", alignItems: "center", gap: 12, paddingHorizontal: 20, paddingVertical: 12 }}>
    <Avatar name="Rachel Chen">RC</Avatar>
    <View style={{ minWidth: 0, flexGrow: 1, flexShrink: 1, flexBasis: "0%" }}>
      <Text style={{ fontSize: 13.5, fontWeight: "600", color: tokens.foreground }}>Rachel Chen</Text>
      <Text style={{ fontSize: 13.5, fontWeight: "600", color: tokens.foreground }}>rachel.chen@example.com</Text>
    </View>
  </View>
</View>
```

### Clickable

**Do** — Wrap the row in a link with a hover background and a trailing chevron to signal drilldown.

```tsx
<StackedList clickable items={[
    { name: "Ada Lovelace", detail: "ada@example.com", meta: "5h ago", initials: "AL" }
  ]} />
```

**Don't** — A drilldown row with no hover state and no chevron gives no hint it is interactive.

```tsx
<StackedList items={[
    { name: "Ada Lovelace", detail: "ada@example.com", meta: "5h ago", initials: "AL" }
  ]} />
```

### Card surface group

**Do** — Separate the titled header with a rule and give each row a trailing action menu.

```tsx
<StackedList card title="Team members" addAction="Add" rowMenu items={[
    { name: "Rachel Chen", detail: "Engineering Lead", initials: "RC" }
  ]} />
```

**Don't** — A header with no rule blends into the rows, and dropping the per-row action removes the affordance.

```tsx
<View style={{ borderRadius: 8, borderWidth: 1, borderColor: tokens.border, backgroundColor: tokens.card, overflow: "hidden", boxShadow: "0px 16px 32px -22px rgba(121, 100, 214, 0.22)", width: "100%", maxWidth: 560 }}>
  <View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between", paddingHorizontal: 20, paddingVertical: 12 }}>
    <Text style={{ fontSize: 14, lineHeight: 20, fontWeight: "600", color: tokens.foreground }}>Team members</Text>
  </View>
  <View style={{ flexDirection: "row", alignItems: "center", gap: 12, paddingHorizontal: 20, paddingVertical: 12 }}>
    <Avatar name="Rachel Chen">RC</Avatar>
    <View style={{ minWidth: 0, flexGrow: 1, flexShrink: 1, flexBasis: "0%" }}>
      <Text style={{ fontSize: 13.5, fontWeight: "600", color: tokens.foreground }}>Rachel Chen</Text>
      <Text style={{ fontSize: 12, lineHeight: 16, color: tokens["muted-foreground"] }}>Engineering Lead</Text>
    </View>
  </View>
</View>
```

### Reorderable rows

**Do** — Give every reorderable row a stable `id` and apply the reported move to your own array: identity is what keeps focus, press state, and the drag announcements attached to the right row.

```tsx
<Stateful initial={[
  { id: "rachel", name: "Rachel Chen", detail: "rachel.chen@example.com" },
  { id: "ada", name: "Ada Lovelace", detail: "ada@example.com" },
]}>
  {(people, setPeople) => (
    <StackedList
      reorderable
      items={people}
      onReorder={({ fromIndex, toIndex }) => {
        const next = [...people];
        next.splice(toIndex, 0, ...next.splice(fromIndex, 1));
        setPeople(next);
      }}
    />
  )}
</Stateful>
```

**Don't** — Don't reorder id-less rows: with the array index as the only key, every drop re-keys the rows under assistive tech and remounts their avatars, and the reported `afterId`/`beforeId` degrade to transient indices no backend can rank by.

```tsx
<StackedList
  reorderable
  items={[
    { name: "Rachel Chen", detail: "rachel.chen@example.com" },
    { name: "Ada Lovelace", detail: "ada@example.com" },
  ]}
  onReorder={() => {}}
/>
```

## Supported props

### StackedListProps

| Prop | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| items | StackedListItem[] \| undefined | no |  | Rows to render. |
| title | ReactNode | no |  | Optional header title; shown above the rows, separated by a rule. It also names the list for assistive tech ("Team, list, 12 items"). |
| label | string \| undefined | no |  | The list's accessible name when it has no `title` (a `title` names the list, and wins when both are set). A screen reader announces it with the list. A `title` that is not a string names the list on the web only, and `label` then names it on iOS and Android. A `virtualized` list needs a name: on the web its scroller is a keyboard stop while the rows overflow, and Chromium names an unnamed stop from the text of every row it has rendered. |
| action | ReactNode | no |  | Trailing header content (e.g. an action button); only shown with a title. Takes precedence over `addAction` when both are supplied. |
| addAction | string \| undefined | no |  | Convenience header action: renders a small outlined button with a leading plus icon and this label (e.g. "Add"). Only shown with a title and when `action` is not set. Serializable, so the playground can drive it. |
| rowMenu | boolean \| undefined | no | false | Appends a trailing ghost overflow ("...") action-menu button to every row. Press is reported through `onPressItemMenu`. |
| onPressItem | ((index: number, event: GestureResponderEvent) => void) \| undefined | no |  | Press handler for a row, by index. Only used in the `clickable` variant. |
| onPressItemMenu | ((index: number, event: GestureResponderEvent) => void) \| undefined | no |  | Press handler for a row's overflow menu, by index. Used with `rowMenu`. |
| clickable | boolean \| undefined | no | false | Variant (pick one; default is the plain two-line list). |
| card | boolean \| undefined | no | false | Variant (pick one; default is the plain two-line list). |
| flush | boolean \| undefined | no | false | Divider modifier: rows are ruled by default; `flush` removes the hairlines. |
| virtualized | boolean \| undefined | no | false | Render the rows through a windowed `FlatList` instead of mounting every row up front, for large lists. Give the list a bounded height (via `style`, e.g. `{ maxHeight: 400 }`) so it can scroll; without one it warns and renders eagerly anyway. Default (omitted) mounts all rows, unchanged. Ignored (with a dev warning) when `reorderable` is set: windowing unmounts off-screen rows, which would leave them undraggable and unmeasurable mid-drag. On the web, while the rows overflow, the windowed list is a keyboard tab stop the keyboard can scroll, and the list's frame draws the focus ring while it has keyboard focus. |
| reorderable | boolean \| undefined | no | false | Make the rows reorderable: each row gains a leading drag grip (keyboard- and screen-reader-operable: Space grabs, the arrow keys move, Space drops, Escape cancels) and the list becomes one drop zone. The order stays CONTROLLED by the consumer's `items` array: a drop only reports the move through `onReorder`, never reorders internally. |
| onReorder | ((move: StackedListMove) => void) \| undefined | no |  | Fired on drop with the computed move (row id, from/to index, new neighbors). |
| testID | string \| undefined | no |  | E2E hook forwarded to the root element. |
| style | LayoutStyle | no |  | Composition within a parent only, never a restyle hook and never a width: the parent layout container provides the bounds. |

See [installation](https://canvas.nannier.com/ai/4.1.0/installation.md) for required peers and feature fallbacks.
