# List

A vertical roving-focus list of navigation and action rows, the primitive sidebars compose.

## Installation

```tsx
import {
  List,
  ListItem,
  ListItemButton,
  ListItemCounter,
  ListItemDisclosureTrigger,
  ListItemLink,
  ListSection,
  ListSectionHeading,
} from "@makeplane/propel/components/list";
```
## Usage

```tsx
import { Icon } from "@makeplane/propel/components/icon";
import { List, ListItem, ListItemButton, ListItemLink } from "@makeplane/propel/components/list";
import {
  MoreHorizontalOutline,
  InboxOutline,
  GridLayoutOutline,
  SettingsOutline,
} from "@makeplane/propel/icons";

export default function BasicDemo() {
  return (
    <div className="w-64">
      <List role="toolbar" aria-label="Workspace">
        <ListItem>
          <ListItemLink
            href="#inbox"
            startIcon={<Icon icon={InboxOutline} size="md" />}
            label="Inbox"
          />
        </ListItem>
        <ListItem>
          <ListItemLink
            href="#projects"
            aria-current="page"
            startIcon={<Icon icon={GridLayoutOutline} size="md" />}
            label="Projects"
          />
        </ListItem>
        <ListItem>
          <ListItemLink
            href="#settings"
            startIcon={<Icon icon={SettingsOutline} size="md" />}
            label="Settings"
          />
        </ListItem>
        <ListItem>
          <ListItemButton
            startIcon={<Icon icon={MoreHorizontalOutline} size="md" />}
            label="More"
          />
        </ListItem>
      </List>
    </div>
  );
}
```
## Examples

### Nesting

`level` (1-5) indents a row to show hierarchy — a workspace containing projects containing pages.

```tsx
import { Icon } from "@makeplane/propel/components/icon";
import { List, ListItem, ListItemLink } from "@makeplane/propel/components/list";
import { InboxOutline, GridLayoutOutline, SettingsOutline } from "@makeplane/propel/icons";

export default function NestingDemo() {
  return (
    <div className="w-64">
      <List role="toolbar" aria-label="Workspace">
        <ListItem level={1}>
          <ListItemLink
            href="#workspace"
            startIcon={<Icon icon={GridLayoutOutline} size="md" />}
            label="Workspace"
          />
        </ListItem>
        <ListItem level={2}>
          <ListItemLink
            href="#project"
            startIcon={<Icon icon={InboxOutline} size="md" />}
            label="Project"
          />
        </ListItem>
        <ListItem level={3}>
          <ListItemLink
            href="#page"
            aria-current="page"
            startIcon={<Icon icon={SettingsOutline} size="md" />}
            label="Page"
          />
        </ListItem>
      </List>
    </div>
  );
}
```

### Text density

`density` sets the row's label/counter text scale: `comfortable` (the default, 14px) or `compact` (13px). Row height and icon size stay fixed either way — this is not a ladder `size` (those labels map to control heights).

```tsx
import { Icon } from "@makeplane/propel/components/icon";
import { List, ListItem, ListItemLink } from "@makeplane/propel/components/list";
import { InboxOutline } from "@makeplane/propel/icons";

export default function DensityDemo() {
  return (
    <div className="w-64">
      <List role="toolbar" aria-label="Text density">
        <ListItem density="comfortable">
          <ListItemLink
            href="#comfortable"
            startIcon={<Icon icon={InboxOutline} size="md" />}
            label="comfortable — 14px"
            count={6}
          />
        </ListItem>
        <ListItem density="compact">
          <ListItemLink
            href="#compact"
            startIcon={<Icon icon={InboxOutline} size="md" />}
            label="compact — 13px"
            count={6}
          />
        </ListItem>
      </List>
    </div>
  );
}
```

### Row disclosure

`ListItemDisclosureTrigger` is a row's own expand/collapse control — a sibling of the row's primary, never nested inside it, since a button can't nest inside a link or button. It is a roving `CompositeItem` in the parent `List` (one tab stop with the primary; arrow keys move between them).

Unlike `ListSection` (which wraps Base UI `Collapsible` for you), there is **no** ready-made row collapsible. Compose it yourself with `Collapsible.Root` from `@base-ui/react/collapsible`:

1. Give **each expandable row its own** `Collapsible.Root`.
2. Inside the root: a parent `List` that holds **only** that expandable row (primary + disclosure trigger).
3. Put `Collapsible.Panel` **next to that parent `List`**, still inside the same root — same shape
   `ListSection` uses, scoped to one row. Wrap the nested child `List` in `ListSectionPanelContent`
   (heading-to-row gap only). Do **not** use `CollapsiblePanelContent` here — its inset and body-xs
   prose are for Collapsible's own panel body; list rows already own their padding.
4. Sibling top-level rows that are not children of the expandable row go in a **separate** `List` _outside_ that root (after it in the stack). Do **not** put those siblings in the same parent `List` as the expandable row: the panel is a sibling of the list, so it would render after every list item and break visual hierarchy.

This example shows `count` and `endIcon` on the expandable row, nested children under it, then a sibling `Settings` row after the cluster.

Trailing order on a row: `count` → `endIcon` → disclosure trigger. `count` and `endIcon` live **inside** the primary (`ListItemLink` / `ListItemButton`) — they are visual only; clicks still hit the link/button. Interactive trailing controls (disclosure, a “more” menu, etc.) must be **siblings** of the primary, never passed as `endIcon`. The row’s full focus ring only follows the primary; sibling controls keep their own focus chrome.

```tsx
import { Collapsible as BaseCollapsible } from "@base-ui/react/collapsible";
import { CollapsiblePanel } from "@makeplane/propel/components/collapsible";
import { Icon } from "@makeplane/propel/components/icon";
import {
  List,
  ListItem,
  ListItemDisclosureTrigger,
  ListItemLink,
  ListSectionPanelContent,
} from "@makeplane/propel/components/list";
import { GridLayoutOutline, SettingsOutline } from "@makeplane/propel/icons";

/**
 * Each expandable row is its own `Collapsible.Root` cluster (parent `List` + panel). Sibling rows
 * that are not children of that cluster sit in a **separate** `List` after the root — never inside
 * the same parent `List` as the expandable row, or the panel would render after those siblings.
 */
export default function RowDisclosureDemo() {
  return (
    <div className="flex w-64 flex-col gap-0.5">
      <BaseCollapsible.Root defaultOpen>
        <List role="toolbar" aria-label="Projects group">
          <ListItem>
            <ListItemLink
              href="#projects"
              startIcon={<Icon icon={GridLayoutOutline} size="md" />}
              label="Projects"
              count={3}
              endIcon={<Icon icon={SettingsOutline} size="md" />}
            />
            <ListItemDisclosureTrigger aria-label="Toggle Projects" />
          </ListItem>
        </List>
        <BaseCollapsible.Panel render={<CollapsiblePanel />}>
          <ListSectionPanelContent>
            <List role="toolbar" aria-label="Projects">
              <ListItem level={2}>
                <ListItemLink
                  href="#roadmap"
                  startIcon={<Icon icon={SettingsOutline} size="md" />}
                  label="Roadmap"
                />
              </ListItem>
            </List>
          </ListSectionPanelContent>
        </BaseCollapsible.Panel>
      </BaseCollapsible.Root>
      <List role="toolbar" aria-label="Workspace">
        <ListItem>
          <ListItemLink
            href="#settings"
            startIcon={<Icon icon={SettingsOutline} size="md" />}
            label="Settings"
          />
        </ListItem>
      </List>
    </div>
  );
}
```

A bare `count` (e.g. `6`) only tells a screen reader "Inbox, 6" — no indication of what the number means, since the component itself can't know (unread messages? total items? something else?). If that distinction matters for your use case, add it yourself with visually-hidden text — `count` accepts any node, not just a string or number:

```tsx
<ListItemLink
  href="#inbox"
  label="Inbox"
  count={
    <>
      <span className="sr-only">unread: </span>6
    </>
  }
/>
```

### Section

Wrap a `List` in `ListSection` to get a muted heading that collapses its body. The disclosure chevron rotates as the section opens and closes.

```tsx
import { Icon } from "@makeplane/propel/components/icon";
import { List, ListItem, ListItemLink, ListSection } from "@makeplane/propel/components/list";
import { GridLayoutOutline, SettingsOutline } from "@makeplane/propel/icons";

export default function SectionDemo() {
  return (
    <div className="w-64">
      <ListSection label="Workspace" indicator defaultOpen>
        <List role="toolbar" aria-label="Workspace">
          <ListItem>
            <ListItemLink
              href="#projects"
              aria-current="page"
              startIcon={<Icon icon={GridLayoutOutline} size="md" />}
              label="Projects"
            />
          </ListItem>
          <ListItem>
            <ListItemLink
              href="#settings"
              startIcon={<Icon icon={SettingsOutline} size="md" />}
              label="Settings"
            />
          </ListItem>
        </List>
      </ListSection>
    </div>
  );
}
```

### Section heading

For settings-style sidebars whose groups never collapse, `ListSectionHeading` titles a `List` with a static, non-interactive label.

```tsx
import { Icon } from "@makeplane/propel/components/icon";
import {
  List,
  ListItem,
  ListItemLink,
  ListSectionHeading,
} from "@makeplane/propel/components/list";
import { AutoReminderOutline, UserAltOutline, MembersOutline } from "@makeplane/propel/icons";

export default function SectionHeadingDemo() {
  return (
    <div className="w-64">
      <ListSectionHeading>Members</ListSectionHeading>
      <List role="toolbar" aria-label="Members">
        <ListItem>
          <ListItemLink
            href="#profile"
            aria-current="page"
            startIcon={<Icon icon={UserAltOutline} size="md" />}
            label="Profile"
          />
        </ListItem>
        <ListItem>
          <ListItemLink
            href="#teams"
            startIcon={<Icon icon={MembersOutline} size="md" />}
            label="Teams"
          />
        </ListItem>
        <ListItem>
          <ListItemLink
            href="#notifications"
            startIcon={<Icon icon={AutoReminderOutline} size="md" />}
            label="Notifications"
          />
        </ListItem>
      </List>
    </div>
  );
}
```

### Controlled

Drive a section's open state yourself with `open` and `onOpenChange`.

```tsx
import { Icon } from "@makeplane/propel/components/icon";
import { List, ListItem, ListItemLink, ListSection } from "@makeplane/propel/components/list";
import { GridLayoutOutline, SettingsOutline } from "@makeplane/propel/icons";
import * as React from "react";

export default function ControlledDemo() {
  const [open, setOpen] = React.useState(true);
  return (
    <div className="w-64">
      <ListSection label="Workspace" indicator open={open} onOpenChange={(next) => setOpen(next)}>
        <List role="toolbar" aria-label="Workspace">
          <ListItem>
            <ListItemLink
              href="#projects"
              aria-current="page"
              startIcon={<Icon icon={GridLayoutOutline} size="md" />}
              label="Projects"
            />
          </ListItem>
          <ListItem>
            <ListItemLink
              href="#settings"
              startIcon={<Icon icon={SettingsOutline} size="md" />}
              label="Settings"
            />
          </ListItem>
        </List>
      </ListSection>
    </div>
  );
}
```
## API Reference

`List` itself forwards Base UI's `Composite` props and the underlying list element's HTML attributes — one tab stop for the whole list, arrow keys move between rows. Pass the `role`/`aria-*` your context calls for.

### ListItem

A row wrapper. Holds a primary `ListItemLink` (or `ListItemButton`) plus optional actions or a
count as siblings, and carries the row chrome. `level` indents nested rows; `density` sets the
label/counter text scale. Renders a `<div>` by default.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement<unknown, string \| JSXElementConstructor<any>> \| ComponentRenderFn<HTMLProps, {}>` | — | Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render. |
| `level` | `1 \| 2 \| 3 \| 4 \| 5` | `1` | Nesting depth — steps the row's left inset by 8px per level (1–5). |
| `density` | `"comfortable" \| "compact"` | `"comfortable"` | Label/counter text density. Row height and icon size stay fixed at both values (`comfortable` = 14px, `compact` = 13px). Not a ladder `size` — height is always 32px. |

### ListItemLink

A list row's primary navigation target — an `<a>` that is also a roving `Composite` item. Base
UI's `CompositeItem` roving behavior grafted onto the styled `elements` `ListItemLink` element
(rule 1a). Mark the current page with `aria-current="page"`. Pass `startIcon` for the leading
visual, `label` for the visible row text, `count` for a trailing count chip, and `endIcon` for a
trailing visual. A row's expand/collapse control (`ListItemDisclosureTrigger`) is a separate
sibling, never a child — a button can't nest inside this `<a>`.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `label` | `string` | — | Visible row label. |
| `startIcon` | `ReactNode` | — | Element rendered before the label, usually `<Icon icon={...} />`. |
| `count` | `ReactNode` | — | Trailing count chip, e.g. an unread total. `0` and `""` are omitted (no empty/zero chip); pass a non-empty node (or the string `"0"`) when a zero must stay visible. |
| `endIcon` | `ReactNode` | — | Trailing visual after the count, usually `<Icon icon={...} />`. Part of the primary hit target — not a separate control. Put interactive trailing actions (disclosure, menus) as siblings of this link, not here. |

### ListItemButton

A list row's primary action — a `<button>` that is also a roving `Composite` item. Base UI's
`CompositeItem` roving behavior grafted onto the styled `elements` `ListItemButton` element (rule
1a). Pass `startIcon` for the leading visual, `label` for the visible row text, `count` for a
trailing count chip, and `endIcon` for a trailing visual. A row's expand/collapse control
(`ListItemDisclosureTrigger`) is a separate sibling, never a child — a button can't nest inside
this `<button>`.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `label` | `string` | — | Visible row label. |
| `startIcon` | `ReactNode` | — | Element rendered before the label, usually `<Icon icon={...} />`. |
| `count` | `ReactNode` | — | Trailing count chip, e.g. an unread total. `0` and `""` are omitted (no empty/zero chip); pass a non-empty node (or the string `"0"`) when a zero must stay visible. |
| `endIcon` | `ReactNode` | — | Trailing visual after the count, usually `<Icon icon={...} />`. Part of the primary hit target — not a separate control. Put interactive trailing actions (disclosure, menus) as siblings of this button, not here. |

### ListItemCounter

The row's trailing count chip (e.g. an unread count).

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement<unknown, string \| JSXElementConstructor<any>> \| ComponentRenderFn<HTMLProps, {}>` | — | Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render. |

### ListItemDisclosureTrigger

A row's expand/collapse control — Base UI `Collapsible.Trigger` grafted onto the styled
trailing-edge element (rule 1a), with the shared rotating caret baked in, and registered as a
roving `CompositeItem` so it participates in the parent `List`'s one-tab-stop / arrow-key
contract (same as `ListItemLink` / `ListItemButton`). Compose as a sibling of the row's primary
inside a `ListItem`, itself inside the `Collapsible.Root` that also wraps the nested `List` panel
— same shape as `ListSection`, just scoped to one row instead of a whole heading.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `aria-label` | `string` | — | Defines a string value that labels the current element. Required: the trigger has no visible text (just the caret), so it must be labeled. @see aria-labelledby. |

### ListSection

A ready-made collapsible list section: a muted heading that toggles its body, with the disclosure
chevron (points inline-end while collapsed, rotates down when open). Pass `label` for the heading
and `children` for the body (typically a `List` of rows); forward `defaultOpen` (uncontrolled) or
`open` + `onOpenChange` (controlled) to drive it. Set `indicator={false}` to omit the chevron.

Children sit in `ListSectionPanelContent` (heading-to-row gap only) inside the height-animating
panel — list rows own their own horizontal padding, so this does not wrap
`CollapsiblePanelContent` (that part carries Collapsible's Figma panel inset + prose).

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `label` (required) | `string` | — | The section heading shown in the toggle. |
| `children` | `ReactNode` | — | The section body — typically a `List` of rows. |
| `indicator` | `boolean` | `true` | Whether to show the rotating disclosure chevron at the heading's inline-end. |
| `render` | `ReactElement<unknown, string \| JSXElementConstructor<any>>` | — | Element to graft the styled frame onto instead of the default `div` — element form only, the callback form's `state` isn't wired through the styled frame. |
