# TaskList

> The list view of tasks — one collapsible group per status, in status order, each holding task cards.

Source: https://raft-design.botiverse.build/components/task-list. Generated from the current release; where it differs from what you remember, this page is right.

A TaskList groups tasks by status, one collapsible group per status, in the same order as the columns of a [TaskBoard](https://raft-design.botiverse.build/components/task-board.md). It is the default view on a phone. When to show which view, and which groups start collapsed, is in [Tasks](https://raft-design.botiverse.build/patterns/tasks.md#rules).

```tsx
import { useCallback, useMemo, useRef, useState } from "react";
import type { ReactNode } from "react";
import {
  ScrollArea,
  ScrollAreaContent,
  ScrollAreaScrollbar,
  ScrollAreaThumb,
  ScrollAreaViewport,
  TaskListRoot,
  TaskSection,
  TaskSectionBadge,
  TaskSectionChevron,
  TaskSectionCount,
  TaskSectionEmpty,
  TaskSectionHeading,
  TaskSectionItems,
  TaskSectionPanel,
  TaskSectionTrigger,
} from "@botiverse/raft-ui";
import { ChevronDown } from "lucide-react";

import {
  getTaskStatusLabel,
  TASK_FIXTURES,
  TASK_STATUSES,
} from "#/modules/task-card/task-card.fixtures.ts";
import type { TaskFixture, TaskStatus } from "#/modules/task-card/task-card.fixtures.ts";
import { TaskCardComposition } from "#/modules/task-card/compositions/task-card.composition.tsx";

const DEFAULT_COLLAPSED: Record<TaskStatus, boolean> = {
  todo: false,
  "in-progress": false,
  "in-review": false,
  done: true,
  closed: true,
};

const EXPANDED: Record<TaskStatus, boolean> = {
  todo: false,
  "in-progress": false,
  "in-review": false,
  done: false,
  closed: false,
};

export type TaskListCompositionProps = {
  tasks?: readonly TaskFixture[];
  showChannelName?: boolean;
  /** When true, every status section starts expanded (useful for empty-section demos). */
  expandAll?: boolean;
};

function TaskListStatusSection({
  children,
  collapsed,
  status,
  onCollapsedChange,
}: {
  children: ReactNode;
  collapsed: boolean;
  status: TaskStatus;
  onCollapsedChange: (status: TaskStatus, collapsed: boolean) => void;
}) {
  const handleOpenChange = useCallback(
    (open: boolean) => onCollapsedChange(status, !open),
    [onCollapsedChange, status],
  );

  return (
    <TaskSection open={!collapsed} onOpenChange={handleOpenChange}>
      {children}
    </TaskSection>
  );
}

export function TaskListComposition({
  tasks: controlledTasks,
  showChannelName = false,
  expandAll = false,
}: TaskListCompositionProps) {
  const portalRef = useRef<HTMLDivElement>(null);
  const [localTasks, setLocalTasks] = useState<readonly TaskFixture[]>(TASK_FIXTURES);
  const [collapsed, setCollapsed] = useState<Record<TaskStatus, boolean>>(
    expandAll ? EXPANDED : DEFAULT_COLLAPSED,
  );
  const [announcement, setAnnouncement] = useState("Ready");

  const tasks = controlledTasks ?? localTasks;
  const sortedTasks = useMemo(
    () => [...tasks].sort((a, b) => b.taskNumber - a.taskNumber),
    [tasks],
  );

  const updateStatus = useCallback(
    (taskId: string, status: TaskStatus) => {
      const task = tasks.find((item) => item.id === taskId);
      if (!task || task.status === status) return;
      const next = tasks.map((item) => (item.id === taskId ? { ...item, status } : item));
      if (controlledTasks === undefined) setLocalTasks(next);
      setAnnouncement(`Task #${task.taskNumber} moved to ${getTaskStatusLabel(status)}`);
    },
    [controlledTasks, tasks],
  );
  const openTask = useCallback((task: TaskFixture) => {
    setAnnouncement(`Opened task #${task.taskNumber}: ${task.title}`);
  }, []);
  const handleCollapsedChange = useCallback((status: TaskStatus, nextCollapsed: boolean) => {
    setCollapsed((current) => ({ ...current, [status]: nextCollapsed }));
  }, []);

  return (
    <ScrollArea ref={portalRef} className="relative size-full min-h-0  min-w-0">
      <ScrollAreaViewport className="p-4">
        <ScrollAreaContent>
          <TaskListRoot>
            {TASK_STATUSES.map((status) => {
              const statusTasks = sortedTasks.filter((task) => task.status === status.id);
              return (
                <TaskListStatusSection
                  key={status.id}
                  status={status.id}
                  collapsed={collapsed[status.id]}
                  onCollapsedChange={handleCollapsedChange}
                >
                  <TaskSectionTrigger>
                    <TaskSectionHeading>
                      <TaskSectionBadge status={status.id}>{status.label}</TaskSectionBadge>
                      <TaskSectionCount>{statusTasks.length}</TaskSectionCount>
                    </TaskSectionHeading>
                    <TaskSectionChevron>
                      <ChevronDown aria-hidden />
                    </TaskSectionChevron>
                  </TaskSectionTrigger>
                  <TaskSectionPanel>
                    {statusTasks.length === 0 ? (
                      <TaskSectionEmpty>No {status.label.toLowerCase()} tasks.</TaskSectionEmpty>
                    ) : (
                      <TaskSectionItems>
                        {statusTasks.map((task) => (
                          <TaskCardComposition
                            key={task.id}
                            task={task}
                            onOpen={openTask}
                            onStatusChange={updateStatus}
                            portalRef={portalRef}
                            showChannelName={showChannelName}
                          />
                        ))}
                      </TaskSectionItems>
                    )}
                  </TaskSectionPanel>
                </TaskListStatusSection>
              );
            })}
          </TaskListRoot>
          <span className="sr-only" aria-live="polite">
            {announcement}
          </span>
        </ScrollAreaContent>
      </ScrollAreaViewport>
      <ScrollAreaScrollbar>
        <ScrollAreaThumb />
      </ScrollAreaScrollbar>
    </ScrollArea>
  );
}
```

## When to use

| Situation                                   | Use                                      |
| ------------------------------------------- | ---------------------------------------- |
| Tasks on a phone or in a narrow panel       | TaskList                                 |
| Tasks by status, with room for five columns | [TaskBoard](https://raft-design.botiverse.build/components/task-board.md) |
| One task                                    | [TaskCard](https://raft-design.botiverse.build/components/task-card.md)    |

## Anatomy

- `TaskListRoot`: the stack of groups. Required.
- `TaskSection`: one status. A collapsible section. Required, one per status.
  - `TaskSectionTrigger`: the group heading, which collapses the group.
    - `TaskSectionChevron`: the arrow before the heading.
    - `TaskSectionHeading`: `TaskSectionBadge` (the status pixel and name) and `TaskSectionCount`.
  - `TaskSectionPanel` and `TaskSectionItems`: the cards.
  - `TaskSectionEmpty`: what an empty group says.

## States

| State       | Look                                                                |
| ----------- | ------------------------------------------------------------------- |
| Collapsed   | Only the heading and its count show                                 |
| Empty group | A dashed `line-seam` box with one line of text in `foreground-hint` |

```tsx
import { TASK_FIXTURES } from "#/modules/task-card/task-card.fixtures.ts";

import { TaskListComposition } from "./task-list.composition.tsx";

const EMPTY_SECTION_TASKS = [TASK_FIXTURES[0], TASK_FIXTURES[2]] as const;

/** Section empties for In Review / Done / Closed. */
export function TaskListEmptySectionsComposition() {
  return <TaskListComposition tasks={EMPTY_SECTION_TASKS} expandAll />;
}
```

## Behavior

- Scroll every group in one viewport. Groups don't scroll on their own.
- Mount only the visible cards of a tall group: make [VirtualList](https://raft-design.botiverse.build/components/virtual-list.md) the viewport, with the group headers and cards as its rows, or use a virtualizer of your own.

## Content

- Write an empty group as "No", the status and "tasks", with the status spelled as on its chip ([Writing](https://raft-design.botiverse.build/content/writing.md#voice)): "No In Review tasks."

## Accessibility

- Each group is a Base UI `Collapsible` rendered as a `section`; its trigger is a button with `aria-expanded`, named by the status and count it contains.
- Show the count in the heading also when the group is collapsed, so the trigger still says how many tasks it hides.

## API

### TaskListRoot

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### TaskSection

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `defaultOpen` | `boolean` | `false` | Whether the collapsible panel is initially open. |
| `disabled` | `boolean` | `false` | Whether the component should ignore user interaction. |
| `onOpenChange` | `(open: boolean, eventDetails: CollapsibleRootChangeEventDetails) => void` |  | Event handler called when the panel is opened or closed. |
| `open` | `boolean` |  | Whether the collapsible panel is currently open. |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### TaskSectionBadge

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `status` (required) | `TaskStatusId` |  |  |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### TaskSectionChevron

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### TaskSectionCount

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### TaskSectionEmpty

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### TaskSectionHeading

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### TaskSectionItems

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### TaskSectionPanel

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `hiddenUntilFound` | `boolean` | `false` | Allows the browser's built-in page search to find and expand the panel contents. |
| `keepMounted` | `boolean` | `false` | Whether to keep the element in the DOM while the panel is hidden. This prop is ignored when `hiddenUntilFound` is used. |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### TaskSectionTrigger

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `nativeButton` | `boolean` | `true` | Whether the component renders a native `<button>` element when replacing it via the `render` prop. Set to `false` if the rendered element is not a button (for example, `<div>`). |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.
