# Virtual Menu

A virtual menu renders a list of hundreds or thousands of menu items by drawing only the rows on screen.

A virtual menu draws only the rows that fit in its height, plus a few above and below, so a list of thousands of items opens as fast as a list of ten. It goes inside a [Dropdown Menu](/docs/dropdown-menu) or a [Context Menu](/docs/context-menu), or stands alone. For a long list that people search rather than scroll, use a [Combobox](/docs/combobox).


## Usage

```tsx
import { Button, DropdownMenu, VirtualMenu } from '@kushagradhawan/kookie-ui';

export function BranchPicker({ branches, onPick }: { branches: { id: string; name: string }[]; onPick: (id: string) => void }) {
  return (
    <DropdownMenu.Root>
      <DropdownMenu.Trigger>
        <Button>Switch branch<DropdownMenu.TriggerIcon /></Button>
      </DropdownMenu.Trigger>
      <DropdownMenu.Content virtualized>
        <VirtualMenu
          items={branches}
          itemLabel="name"
          aria-label="Branches"
          onSelect={(branch) => onPick(branch.id)}
          style={{ height: 320 }}
        />
      </DropdownMenu.Content>
    </DropdownMenu.Root>
  );
}
```

## Anatomy

```tsx
<VirtualMenu items={items} renderItem={Row} />

const Row = React.memo(({ item, ...props }) => (
  <VirtualMenu.Item {...props}>{item.name}</VirtualMenu.Item>
));
```

- **VirtualMenu:** The scrolling list. It measures the visible area and draws the rows inside it.
- **VirtualMenu.Item:** One row, for a custom layout through `renderItem`.

Inside a menu, import `VirtualMenu` and place it in the menu's `Content`. Menus have no `Virtual` part.

## Best practices

**Use it only for long lists.** Below about a hundred items, ordinary menu items are simpler and keep every keyboard feature of the parent menu. Reach for a virtual menu when the list is long enough to slow the menu down.

**Set `virtualized` on the parent Content.** Without it, the menu wraps the list in its own scroll area, and the list can't measure the space it draws into.

**Give the list a fixed height.** The list draws only what fits in its height, so it needs one. Set `height` in `style`, or through a class.

**Keep every row the same height.** The list places each row from the heights it is given, and one height needs no setup. When rows must differ, pass a function to `estimatedItemSize`.

## Items

Pass `itemLabel` for rows of plain text. It accepts the name of a property, such as `"name"`, or a function that returns the label. Pass `renderItem` for rows with icons, avatars or a second line of text. One of the two is required, and TypeScript reports a list that has neither.

A `renderItem` component receives the item, its index and the props that position and wire up the row. Spread those props onto `VirtualMenu.Item`, and wrap the component in `React.memo` at module scope, so a scroll redraws only the rows that move.

```tsx
import { Avatar, Flex, Text, VirtualMenu, type VirtualMenuRenderItemProps } from '@kushagradhawan/kookie-ui';

const PersonRow = React.memo(function PersonRow({ item, isHighlighted, index, ...props }: VirtualMenuRenderItemProps<Person>) {
  return (
    <VirtualMenu.Item {...props}>
      <Avatar size="1" src={item.avatar} fallback={item.initials} />
      <Text size="2">{item.name}</Text>
    </VirtualMenu.Item>
  );
});
```

## Size

Row height follows the size of the parent menu, so a virtual list in a size 1 `DropdownMenu.Content` uses size 1 rows. Inside a menu, the list reads the row height the CSS draws, so rows stay in step with the theme's scale. Outside a menu, such as in a Popover, the list draws the same rows and highlight as a menu at its own `size`, which is `'2'` unless you set it. Set `estimatedItemSize` only for rows taller than a menu row.

## Examples

### Assigning from a large team

Use `renderItem` to show an avatar beside each name, so people can tell two people with similar names apart.

```tsx
<DropdownMenu.Content virtualized>
  <VirtualMenu items={members} renderItem={PersonRow} aria-label="Team members" onSelect={assign} style={{ height: 360 }} />
</DropdownMenu.Content>
```

### Rows of different heights

Pass a function to `estimatedItemSize` that returns the height of each row in pixels. The list gives each row that height through the `style` it passes to the row.

```tsx
<VirtualMenu
  items={rows}
  renderItem={SectionRow}
  estimatedItemSize={(index) => (rows[index].type === 'heading' ? 40 : 30)}
  style={{ height: 320 }}
/>
```

## Props

### VirtualMenu

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `items` | `T[]` | | The items to list. Required |
| `itemLabel` | `keyof T \| (item: T) => ReactNode` | | The label of each row, for plain text rows |
| `renderItem` | `ComponentType<VirtualMenuRenderItemProps<T>>` | | A component that draws each row. Wrap it in `React.memo` |
| `onSelect` | `(item: T, index: number) => void` | | Called when someone chooses a row. Inside a menu, the menu then closes |
| `size` | `'1' \| '2' \| '3' \| '4'` | Parent menu's size, else `'2'` | The row height step |
| `estimatedItemSize` | `number \| (index: number) => number` | The row height for `size` | The height of each row in pixels |
| `overscan` | `number` | `5` | Rows drawn above and below the visible area |
| `aria-label` | `string` | | The name of the list |
| `aria-labelledby`, `id`, `data-*` | | | Passed to the list element, with the other attributes of the HTML `div` element |
| `className` | `string` | | A class for the list |
| `style` | `CSSProperties` | | Styles for the list. Set `height` here or in the class |
| `ref` | `Ref<HTMLDivElement>` | | The list element, for moving focus to it |

### VirtualMenu.Item

VirtualMenu.Item accepts every attribute of the HTML `div` element. Pass it the props from `renderItem`. It redraws when any of its props change, other than a new `style` object with the same height and position.

### Render item props

| Prop | Type | Description |
| --- | --- | --- |
| `item` | `T` | The item for this row |
| `index` | `number` | The position of the item in `items` |
| `isHighlighted` | `boolean` | Whether the row is highlighted |
| `id`, `style`, `tabIndex` | | The id, position and tab stop of the row. Pass them through |
| `aria-setsize`, `aria-posinset` | `number` | The length of the whole list and the row's place in it, since only some rows are drawn. Pass them through |
| `data-highlighted`, `data-index` | | Attributes for the highlight style and the event handlers. Pass them through |
| `onClick`, `onKeyDown`, `onMouseEnter`, `onMouseLeave` | | The handlers that choose and highlight the row. Pass them through |

## Accessibility

| Key | Action |
| --- | --- |
| Arrow Down or Arrow Up | Moves the highlight, and wraps at the ends |
| Home or End | Moves the highlight to the first or last row |
| Enter or Space | Chooses the highlighted row |
| Escape | Takes focus off the list. Inside a menu, it closes the menu and returns focus to the trigger |
| Tab | On its own, moves focus to the next element on the page. Inside a menu, it does nothing, as in any menu |

- **Roles:** Each row has the `menuitem` role. On its own, the list has the `menu` role. Inside a Dropdown Menu or a Context Menu, it has the `group` role, because the parent is the menu.
- **Name:** Set `aria-label` so a screen reader can name the list.
- **Highlight:** The list keeps focus and points `aria-activedescendant` at the highlighted row, so a screen reader announces each row as the highlight moves. Moving the pointer off a row clears its highlight.
- **Position:** Each row sets `aria-setsize` and `aria-posinset`, so a screen reader announces the length of the whole list, not only the rows drawn.
- **Letters:** Inside a menu, typing a letter while the list has focus does not move focus to a menu item outside the list.
- **Focus:** The list is one tab stop. Inside a menu, the list takes focus when the menu opens and highlights the first row, so the arrow keys reach the rows straight away. Choosing a row closes the menu and returns focus to its trigger.
