# 列表

> 列表的行怎么画：没有分隔线；悬停、按下、菜单打开、选中和焦点都画在同一个内缩的圆角形状上；内容在这个形状里面留 12px 内边距；列表用自己的间隔让行与行相隔 2px；滚动容器让形状两边的距离保持一致。

Source: https://raft-design.botiverse.build/zh/patterns/lists. Translated from https://raft-design.botiverse.build/patterns/lists.md; where the two differ, the English page is right.

列表的一行是一个单元格（cell）。这一行横跨整个列表的宽度，它的各种状态画在行内的一个圆角形状上：和行一样高，离两边各内缩 8px。列表用它的 `gap` 让行与行相隔 2px，上下各留 8px 内边距，和形状离两边一样远，所以两行都有填充时，中间露出 2px 的列表，不用线就把两行分开了：列表没有分隔线。行的内容以形状为基准留内边距，两端都在形状里面 12px，所以行里没有任何东西碰到它的形状。

## 适用范围
所有由行组成、每一行都是一个可以打开或选取的东西的列表：

- 动态和通知：[InboxItem](https://raft-design.botiverse.build/zh/components/inbox-item.md)。
- 侧栏，以及手机上用侧栏行画的地点列表：[Sidebar](https://raft-design.botiverse.build/zh/components/sidebar.md)。
- 搜索结果：[Search](https://raft-design.botiverse.build/zh/components/search.md)（`SearchEntityResult`、`SearchMessageResult`）。
- 手机上的地点列表和人的列表，以及进入其他页面的入口：带 `cell` 的 [ListRow](https://raft-design.botiverse.build/zh/components/list-row.md)。

不适用于：

- 表格：表格的行与行之间保留细线，因为它的列要横着读（[Table](https://raft-design.botiverse.build/zh/components/table.md)）。
- 设置列表：设置卡片里的行（由 `plain` ListRow 组成的 `ListRowGroup`）保留分隔线和整行宽的填充。一张设置卡片是一张从上读到下的表单，线把每一项设置和下一项连在一起；在卡片自己的圆角边框里再放一个内缩的圆角形状，只会白白多画出第三道边（设计评审，2026-10-26）。
- 消息下方和侧边卡片里紧凑的任务行和文件行，以及任务页面的任务行，它们暂时保留自己的线（[视觉舒适度](https://raft-design.botiverse.build/zh/foundations/comfort.md#separators)；[任务](https://raft-design.botiverse.build/zh/patterns/tasks.md#the-list)）。
- 弹出菜单和选项列表，它们的行遵循[间距](https://raft-design.botiverse.build/zh/foundations/spacing.md)。
- 卡片列表（任务卡片、文件面板的卡片）：每张卡片都是一个有自己边缘的对象，整张卡片都是点击目标，卡片之间的间隔保留。

## 结构
```tsx
import {
  Activity,
  ArrowLeft,
  AtSign,
  Bookmark,
  CheckCircle2,
  ChevronDown,
  Inbox,
  Search,
  X,
} from "lucide-react";
import {
  ActivityInboxControls,
  ActivityInboxPanel,
  ActivityInboxSelectionCount,
  ActivityInboxSelectionToolbar,
  ActivityInboxSidebar,
  ActivityInboxViewport,
  Button,
  Checkbox,
  ConversationPanelContent,
  Dialog,
  DialogBody,
  DialogClose,
  DialogContent,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuRadioGroup,
  DropdownMenuRadioItem,
  DropdownMenuTrigger,
  EmptyState,
  EmptyStateContent,
  EmptyStateDescription,
  EmptyStateIcon,
  EmptyStateTitle,
  InputGroup,
  InputGroupAddon,
  InputGroupInput,
  PanelActions,
  PanelHeader,
  PanelHeaderContent,
  PanelHeaderIcon,
  PanelHeading,
  PanelMeta,
  PanelTitle,
  Separator,
  SidebarGroupLabel,
  SidebarSectionTrailing,
  Tabs,
  TabsLabel,
  TabsList,
  TabsTab,
  TextLink,
} from "@botiverse/raft-ui";
import { useCallback, useMemo, useRef, useState } from "react";
import type { ChangeEvent } from "react";

import { ACTIVITY_COMPOSITION_ITEMS } from "../activity.fixtures.ts";
import type {
  ActivityCompositionItem,
  ActivityCompositionChannelKind,
} from "../activity.fixtures.ts";
import {
  ActivityGroupIcon,
  ActivityItem,
  ActivityNavigationItem,
  ActivitySwitcherRow,
} from "../activity-inbox.helpers.tsx";
import type { ActivityView } from "../activity-inbox.helpers.tsx";

type ActivityReadFilter = "all" | "unread";
type ActivitySortDirection = "desc" | "asc";

export type ActivityInboxCompositionProps = {
  items?: readonly ActivityCompositionItem[];
  defaultItems?: readonly ActivityCompositionItem[];
  onItemsChange?: (items: readonly ActivityCompositionItem[]) => void;
  onOpenItem?: (item: ActivityCompositionItem, detail: number) => void;
  openItemId?: string | null;
  onBack?: () => void;
  defaultView?: ActivityView;
};

export function ActivityInboxComposition({
  items: controlledItems,
  defaultItems = ACTIVITY_COMPOSITION_ITEMS,
  onItemsChange,
  onOpenItem,
  openItemId: controlledOpenItemId,
  onBack,
  defaultView = "active",
}: ActivityInboxCompositionProps = {}) {
  const [localItems, setLocalItems] = useState<readonly ActivityCompositionItem[]>(defaultItems);
  const items = controlledItems ?? localItems;
  const [view, setView] = useState<ActivityView>(defaultView);
  const [readFilter, setReadFilter] = useState<ActivityReadFilter>("all");
  const [mentionsOnly, setMentionsOnly] = useState(false);
  const [channelFilterId, setChannelFilterId] = useState<string>();
  const [query, setQuery] = useState("");
  const [sortDirection, setSortDirection] = useState<ActivitySortDirection>("desc");
  const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set());
  const [localOpenItemId, setLocalOpenItemId] = useState<string>();
  const openItemId =
    controlledOpenItemId === undefined ? localOpenItemId : (controlledOpenItemId ?? undefined);
  const [mobileSwitcherOpen, setMobileSwitcherOpen] = useState(false);
  const rootRef = useRef<HTMLDivElement>(null);
  const portalProps = useMemo(() => ({ container: rootRef }), []);

  const groups = useMemo(() => {
    const counts = new Map<string, ActivityGroup>();
    for (const item of items) {
      if (item.done) continue;
      const current = counts.get(item.channelId);
      if (current) current.count += 1;
      else {
        counts.set(item.channelId, {
          id: item.channelId,
          label: item.channelLabel.replace(/^#/, ""),
          count: 1,
          channelKind: item.channelKind,
        });
      }
    }
    return [...counts.values()];
  }, [items]);
  const visibleItems = useMemo(() => {
    const normalizedQuery = query.trim().toLocaleLowerCase();
    const source = items.filter((item) => {
      if (view === "active") return !item.done;
      if (view === "saved") return item.saved;
      return item.done;
    });
    const filtered = source.filter((item) => {
      if (view === "active" && readFilter === "unread" && item.unreadCount === 0) return false;
      if (view === "active" && mentionsOnly && !item.hasMention) return false;
      if (view === "active" && channelFilterId && item.channelId !== channelFilterId) return false;
      if (!normalizedQuery) return true;
      return [item.title, item.channelLabel, item.sender, item.preview]
        .join(" ")
        .toLocaleLowerCase()
        .includes(normalizedQuery);
    });
    return [...filtered].sort((left, right) => {
      const comparison = left.sortAt.localeCompare(right.sortAt);
      return sortDirection === "asc" ? comparison : -comparison;
    });
  }, [channelFilterId, items, mentionsOnly, query, readFilter, sortDirection, view]);
  const selectedItems = useMemo(
    () => visibleItems.filter((item) => selectedIds.has(item.id)),
    [selectedIds, visibleItems],
  );
  const allVisibleSelected =
    visibleItems.length > 0 && selectedItems.length === visibleItems.length;
  const someVisibleSelected = selectedItems.length > 0 && !allVisibleSelected;

  const commitItems = useCallback(
    (next: readonly ActivityCompositionItem[]) => {
      if (!controlledItems) setLocalItems(next);
      onItemsChange?.(next);
    },
    [controlledItems, onItemsChange],
  );

  const updateItem = useCallback(
    (id: string, update: (item: ActivityCompositionItem) => ActivityCompositionItem) => {
      commitItems(items.map((item) => (item.id === id ? update(item) : item)));
    },
    [commitItems, items],
  );

  const setActivityView = useCallback((nextView: ActivityView) => {
    setView(nextView);
    setSelectedIds(new Set());
    if (nextView !== "active") {
      setReadFilter("all");
      setMentionsOnly(false);
      setChannelFilterId(undefined);
    }
  }, []);

  const toggleMentionsOnly = useCallback(() => {
    setActivityView("active");
    setMentionsOnly((current) => !current);
  }, [setActivityView]);

  const toggleChannelFilter = useCallback(
    (channelId: string) => {
      setActivityView("active");
      setChannelFilterId((current) => (current === channelId ? undefined : channelId));
    },
    [setActivityView],
  );

  const toggleAllVisible = useCallback(() => {
    setSelectedIds((current) => {
      const next = new Set(current);
      if (allVisibleSelected) visibleItems.forEach((item) => next.delete(item.id));
      else visibleItems.forEach((item) => next.add(item.id));
      return next;
    });
  }, [allVisibleSelected, visibleItems]);

  const runBulkAction = useCallback(
    (action: "read" | "done" | "restore" | "unsave") => {
      const ids = new Set(selectedItems.map((item) => item.id));
      commitItems(
        items.map((item) => {
          if (!ids.has(item.id)) return item;
          if (action === "read") return { ...item, unreadCount: 0, hasMention: false };
          if (action === "done") return { ...item, done: true };
          if (action === "restore") return { ...item, done: false };
          return { ...item, saved: false };
        }),
      );
      setSelectedIds(new Set());
    },
    [commitItems, items, selectedItems],
  );

  const totalActive = items.filter((item) => !item.done).length;
  const savedTotal = items.filter((item) => item.saved).length;

  const switchInMobileDialog = useCallback(
    (nextView: ActivityView) => {
      setActivityView(nextView);
      setMobileSwitcherOpen(false);
    },
    [setActivityView],
  );

  const handleMobileSwitchToActive = useCallback(() => {
    switchInMobileDialog("active");
  }, [switchInMobileDialog]);
  const handleMobileSwitchToSaved = useCallback(() => {
    switchInMobileDialog("saved");
  }, [switchInMobileDialog]);
  const handleMobileSwitchToDone = useCallback(() => {
    switchInMobileDialog("done");
  }, [switchInMobileDialog]);

  const handleMobileToggleMentions = useCallback(() => {
    toggleMentionsOnly();
    setMobileSwitcherOpen(false);
  }, [toggleMentionsOnly]);

  const handleMobileToggleChannelFilter = useCallback(
    (channelId: string) => {
      toggleChannelFilter(channelId);
      setMobileSwitcherOpen(false);
    },
    [toggleChannelFilter],
  );

  const handleSwitchToActive = useCallback(() => {
    setActivityView("active");
  }, [setActivityView]);
  const handleSwitchToSaved = useCallback(() => {
    setActivityView("saved");
  }, [setActivityView]);
  const handleSwitchToDone = useCallback(() => {
    setActivityView("done");
  }, [setActivityView]);

  const handleShowAllChannels = useCallback(() => {
    setChannelFilterId(undefined);
  }, []);

  const handleQueryChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
    setQuery(event.target.value);
  }, []);
  const handleClearQuery = useCallback(() => {
    setQuery("");
  }, []);

  const handleSortDirectionChange = useCallback((value: ActivitySortDirection) => {
    setSortDirection(value);
  }, []);

  const handleMarkSelectedRead = useCallback(() => {
    runBulkAction("read");
  }, [runBulkAction]);
  const handleMarkSelectedDone = useCallback(() => {
    runBulkAction("done");
  }, [runBulkAction]);
  const handleRestoreSelected = useCallback(() => {
    runBulkAction("restore");
  }, [runBulkAction]);
  const handleUnsaveSelected = useCallback(() => {
    runBulkAction("unsave");
  }, [runBulkAction]);

  const handleOpenItem = useCallback(
    (item: ActivityCompositionItem, detail: number) => {
      setLocalOpenItemId(item.id);
      onOpenItem?.(item, detail);
    },
    [onOpenItem],
  );

  const handleSelectItem = useCallback((item: ActivityCompositionItem) => {
    setSelectedIds((current) => {
      const next = new Set(current);
      if (next.has(item.id)) next.delete(item.id);
      else next.add(item.id);
      return next;
    });
  }, []);

  const handleToggleItemSaved = useCallback(
    (item: ActivityCompositionItem) => {
      updateItem(item.id, (candidate) => ({ ...candidate, saved: !candidate.saved }));
    },
    [updateItem],
  );

  const handleToggleItemDone = useCallback(
    (item: ActivityCompositionItem) => {
      updateItem(item.id, (candidate) => ({ ...candidate, done: !candidate.done }));
    },
    [updateItem],
  );

  const handleToggleItemRead = useCallback(
    (item: ActivityCompositionItem) => {
      updateItem(item.id, (candidate) => ({
        ...candidate,
        hasMention: candidate.unreadCount > 0 ? false : candidate.hasMention,
        unreadCount: candidate.unreadCount > 0 ? 0 : 1,
      }));
    },
    [updateItem],
  );

  const handleToggleItemFollowing = useCallback(
    (item: ActivityCompositionItem) => {
      updateItem(item.id, (candidate) => ({
        ...candidate,
        following: candidate.following === false,
      }));
    },
    [updateItem],
  );

  return (
    <ActivityInboxPanel ref={rootRef}>
      <PanelHeader>
        {onBack ? (
          <Button
            type="button"
            variant="outline"
            size="icon-md"
            onClick={onBack}
            aria-label="Back to Home"
            className="md:hidden"
          >
            <ArrowLeft size={14} />
          </Button>
        ) : null}
        <PanelHeaderIcon className="max-md:hidden">
          <Activity aria-hidden className="size-4.5" />
        </PanelHeaderIcon>
        <PanelHeaderContent
          className={onBack ? "max-md:col-start-2 max-md:col-span-2" : "max-md:col-span-3"}
        >
          <PanelHeading>
            <PanelTitle render={<h2 />}>Activity</PanelTitle>
          </PanelHeading>
          <PanelMeta render={<p />}>Triage what needs your attention</PanelMeta>
        </PanelHeaderContent>
        <PanelActions className="md:hidden">
          <Dialog open={mobileSwitcherOpen} onOpenChange={setMobileSwitcherOpen}>
            <DialogTrigger
              render={
                <Button
                  type="button"
                  variant="outline"
                  size="md"
                  aria-label="Switch Activity"
                  className="min-w-0 max-w-28"
                >
                  {view === "active" ? (
                    <Inbox />
                  ) : view === "saved" ? (
                    <Bookmark />
                  ) : (
                    <CheckCircle2 />
                  )}
                  <span className="min-w-0 flex-1 truncate text-left">
                    {view === "active" ? "Active" : view === "saved" ? "Saved" : "Done"}
                  </span>
                  <ChevronDown />
                </Button>
              }
            />
            <DialogContent portalProps={portalProps}>
              <DialogHeader>
                <DialogTitle>Switch Activity</DialogTitle>
                <DialogClose aria-label="Close" />
              </DialogHeader>
              <DialogBody className="flex flex-col gap-1">
                <ActivitySwitcherRow
                  active={view === "active"}
                  icon={<Inbox />}
                  label="Active"
                  count={totalActive}
                  onClick={handleMobileSwitchToActive}
                />
                <ActivitySwitcherRow
                  active={view === "saved"}
                  icon={<Bookmark />}
                  label="Saved"
                  count={savedTotal || undefined}
                  onClick={handleMobileSwitchToSaved}
                />
                <ActivitySwitcherRow
                  active={view === "done"}
                  icon={<CheckCircle2 />}
                  label="Done"
                  onClick={handleMobileSwitchToDone}
                />
                <Separator className="mx-3 my-3" />
                <ActivitySwitcherRow
                  pressed={view === "active" && mentionsOnly}
                  icon={<AtSign />}
                  label="Mentions"
                  onClick={handleMobileToggleMentions}
                />
                <Separator className="mx-3 my-3" />
                {groups.map((group) => (
                  <ActivityGroupSwitcherRow
                    key={group.id}
                    group={group}
                    active={view === "active" && channelFilterId === group.id}
                    onToggle={handleMobileToggleChannelFilter}
                  />
                ))}
              </DialogBody>
            </DialogContent>
          </Dialog>
        </PanelActions>
      </PanelHeader>

      <div className="flex min-h-0 flex-1">
        <ActivityInboxSidebar>
          <div className="flex flex-col gap-0.5 py-2">
            <ActivityNavigationItem
              active={view === "active"}
              icon={<Inbox size={15} />}
              label="Active"
              count={totalActive}
              onClick={handleSwitchToActive}
            />
            <ActivityNavigationItem
              active={view === "saved"}
              icon={<Bookmark size={15} />}
              label="Saved"
              count={savedTotal || undefined}
              onClick={handleSwitchToSaved}
            />
            <ActivityNavigationItem
              active={view === "done"}
              icon={<CheckCircle2 size={15} />}
              label="Done"
              onClick={handleSwitchToDone}
            />
          </div>

          <SidebarGroupLabel>Filters</SidebarGroupLabel>
          <div className="flex flex-col gap-0.5 py-2">
            <ActivityNavigationItem
              pressed={view === "active" && mentionsOnly}
              disabled={view !== "active"}
              icon={<AtSign size={14} />}
              label="Mentions"
              onClick={toggleMentionsOnly}
            />
          </div>

          <SidebarGroupLabel>
            <span className="min-w-0 flex-1 truncate whitespace-nowrap">DM &amp; Channels</span>
            {channelFilterId ? (
              <SidebarSectionTrailing className="min-w-0 pr-0">
                <TextLink data-activity-inbox-show-all="" onClick={handleShowAllChannels}>
                  Show all
                </TextLink>
              </SidebarSectionTrailing>
            ) : null}
          </SidebarGroupLabel>
          <div className="flex flex-col gap-0.5 py-2">
            {groups.map((group) => (
              <ActivityGroupNavigationItem
                key={group.id}
                group={group}
                active={view === "active" && channelFilterId === group.id}
                onToggle={toggleChannelFilter}
              />
            ))}
          </div>
        </ActivityInboxSidebar>

        <ConversationPanelContent
          render={<section />}
          data-slot="activity-inbox-main"
          className="md:min-w-[30rem]"
        >
          <ActivityInboxControls>
            {view === "active" ? (
              <Tabs<ActivityReadFilter>
                className="w-auto shrink-0"
                value={readFilter}
                onValueChange={setReadFilter}
              >
                <TabsList aria-label="Filter activity">
                  <TabsTab value="all">
                    <TabsLabel>All</TabsLabel>
                  </TabsTab>
                  <TabsTab value="unread">
                    <TabsLabel>Unread</TabsLabel>
                  </TabsTab>
                </TabsList>
              </Tabs>
            ) : null}

            <InputGroup className="min-w-0 flex-1">
              <InputGroupAddon align="inline-start">
                <Search aria-hidden size={15} />
              </InputGroupAddon>
              <InputGroupInput
                value={query}
                onChange={handleQueryChange}
                placeholder="Search activity"
                aria-label="Search activity"
              />
              <InputGroupAddon align="inline-end">
                {query ? (
                  <Button
                    type="button"
                    variant="ghost"
                    size="icon-md"
                    onClick={handleClearQuery}
                    aria-label="Clear search"
                  >
                    <X aria-hidden />
                  </Button>
                ) : null}
              </InputGroupAddon>
            </InputGroup>
          </ActivityInboxControls>

          <ActivityInboxSelectionToolbar>
            <Checkbox
              checked={allVisibleSelected}
              indeterminate={someVisibleSelected}
              disabled={visibleItems.length === 0}
              onCheckedChange={toggleAllVisible}
              aria-label="Select all visible activity"
            />
            {selectedItems.length === 0 ? (
              <DropdownMenu>
                <DropdownMenuTrigger
                  render={
                    <Button
                      type="button"
                      size="md"
                      variant="ghost"
                      data-activity-inbox-action="sort"
                      className="ml-auto"
                    />
                  }
                >
                  {sortDirection === "desc" ? "Newest to oldest" : "Oldest to newest"}
                  <ChevronDown />
                </DropdownMenuTrigger>
                <DropdownMenuContent
                  align="end"
                  side="bottom"
                  sideOffset={8}
                  portalProps={portalProps}
                >
                  <DropdownMenuRadioGroup
                    value={sortDirection}
                    onValueChange={handleSortDirectionChange}
                  >
                    {(["desc", "asc"] as const).map((direction) => (
                      <DropdownMenuRadioItem key={direction} value={direction}>
                        {direction === "desc" ? "Newest to oldest" : "Oldest to newest"}
                      </DropdownMenuRadioItem>
                    ))}
                  </DropdownMenuRadioGroup>
                </DropdownMenuContent>
              </DropdownMenu>
            ) : (
              <>
                <ActivityInboxSelectionCount>
                  {selectedItems.length} selected
                </ActivityInboxSelectionCount>
                {view === "active" ? (
                  <>
                    <Button
                      type="button"
                      size="md"
                      variant="outline"
                      onClick={handleMarkSelectedRead}
                    >
                      Mark read
                    </Button>
                    <Button
                      type="button"
                      size="md"
                      variant="primary"
                      onClick={handleMarkSelectedDone}
                    >
                      Mark done
                    </Button>
                  </>
                ) : view === "done" ? (
                  <Button
                    type="button"
                    size="md"
                    variant="primary"
                    data-activity-inbox-action="primary"
                    onClick={handleRestoreSelected}
                  >
                    Restore
                  </Button>
                ) : (
                  <Button
                    type="button"
                    size="md"
                    variant="primary"
                    data-activity-inbox-action="primary"
                    onClick={handleUnsaveSelected}
                  >
                    Remove from Saved
                  </Button>
                )}
              </>
            )}
          </ActivityInboxSelectionToolbar>

          <ActivityInboxViewport>
            {visibleItems.length === 0 ? (
              <EmptyState className="flex-1">
                <EmptyStateContent>
                  <EmptyStateIcon>
                    {view === "saved" ? (
                      <Bookmark />
                    ) : view === "done" ? (
                      <CheckCircle2 />
                    ) : (
                      <Activity />
                    )}
                  </EmptyStateIcon>
                  <EmptyStateTitle>
                    {query
                      ? "No matching activity"
                      : view === "saved"
                        ? "No saved items"
                        : view === "done"
                          ? "No completed items"
                          : "Activity is empty"}
                  </EmptyStateTitle>
                  <EmptyStateDescription>
                    {view === "done"
                      ? "Items marked as done appear here and can be restored."
                      : "New replies and mentions will appear here."}
                  </EmptyStateDescription>
                </EmptyStateContent>
              </EmptyState>
            ) : (
              <div className="flex flex-col gap-0.5">
                {visibleItems.map((item) => (
                  <ActivityItem
                    key={item.id}
                    item={item}
                    active={openItemId === item.id}
                    selected={selectedIds.has(item.id)}
                    view={view}
                    portalContainer={rootRef}
                    onOpen={handleOpenItem}
                    onSelect={handleSelectItem}
                    onToggleSaved={handleToggleItemSaved}
                    onToggleDone={handleToggleItemDone}
                    onToggleRead={handleToggleItemRead}
                    onToggleFollowing={handleToggleItemFollowing}
                  />
                ))}
              </div>
            )}
          </ActivityInboxViewport>
        </ConversationPanelContent>
      </div>
    </ActivityInboxPanel>
  );
}

type ActivityGroup = {
  id: string;
  label: string;
  count: number;
  channelKind: ActivityCompositionChannelKind;
};

type ActivityGroupRowProps = {
  group: ActivityGroup;
  active: boolean;
  onToggle: (channelId: string) => void;
};

function ActivityGroupSwitcherRow({ group, active, onToggle }: ActivityGroupRowProps) {
  const handleClick = useCallback(() => {
    onToggle(group.id);
  }, [group.id, onToggle]);

  return (
    <ActivitySwitcherRow
      active={active}
      icon={<ActivityGroupIcon group={group} mobile />}
      label={group.label}
      count={group.count}
      onClick={handleClick}
    />
  );
}

function ActivityGroupNavigationItem({ group, active, onToggle }: ActivityGroupRowProps) {
  const handleClick = useCallback(() => {
    onToggle(group.id);
  }, [group.id, onToggle]);

  return (
    <ActivityNavigationItem
      active={active}
      icon={<ActivityGroupIcon group={group} />}
      label={group.label}
      count={group.count}
      onClick={handleClick}
    />
  );
}
```

```tsx
import { useCallback, useState } from "react";
import type { MouseEvent } from "react";
import { AtSign } from "lucide-react";
import {
  Avatar,
  AvatarBadge,
  AvatarFallback,
  Badge,
  ListRow,
  ListRowBody,
  ListRowBottom,
  ListRowDescription,
  ListRowMeta,
  ListRowTitle,
  ListRowTop,
} from "@botiverse/raft-ui";

export function ListRowConversationsComposition() {
  const [open, setOpen] = useState("wug");
  const handleOpen = useCallback((event: MouseEvent<HTMLButtonElement>) => {
    setOpen(event.currentTarget.value);
  }, []);

  return (
    <div className="flex w-full max-w-sm flex-col gap-0.5 py-2">
      <ListRow
        cell
        value="wug"
        tone="unread"
        selected={open === "wug"}
        aria-current={open === "wug"}
        onClick={handleOpen}
      >
        <Avatar size="md" type="agent">
          <AvatarFallback>W</AvatarFallback>
          <AvatarBadge role="img" aria-label="Online" />
        </Avatar>
        <ListRowBody>
          <ListRowTop>
            <ListRowTitle>Wug</ListRowTitle>
            <ListRowMeta>10:40</ListRowMeta>
          </ListRowTop>
          <ListRowBottom>
            <ListRowDescription>Draft is in #launch-week.</ListRowDescription>
            <Badge variant="accent" role="img" aria-label="1 unread">
              1
            </Badge>
          </ListRowBottom>
        </ListRowBody>
      </ListRow>
      <ListRow
        cell
        value="martin"
        tone="unread"
        selected={open === "martin"}
        aria-current={open === "martin"}
        onClick={handleOpen}
      >
        <Avatar size="md" type="agent">
          <AvatarFallback>M</AvatarFallback>
        </Avatar>
        <ListRowBody>
          <ListRowTop>
            <ListRowTitle>Martin</ListRowTitle>
            <ListRowMeta>10:31</ListRowMeta>
          </ListRowTop>
          <ListRowBottom>
            <ListRowDescription>Bugen claimed #25 Polish onboarding flow</ListRowDescription>
            <Badge variant="you" role="img" aria-label="Mentions you">
              <AtSign />
            </Badge>
          </ListRowBottom>
        </ListRowBody>
      </ListRow>
      <ListRow
        cell
        value="mia"
        selected={open === "mia"}
        aria-current={open === "mia"}
        onClick={handleOpen}
      >
        <Avatar size="md" type="human">
          <AvatarFallback>MC</AvatarFallback>
        </Avatar>
        <ListRowBody>
          <ListRowTop>
            <ListRowTitle>Mia Chen</ListRowTitle>
            <ListRowMeta>Tue</ListRowMeta>
          </ListRowTop>
          <ListRowBottom>
            <ListRowDescription>Can we try the yellow tab bar?</ListRowDescription>
          </ListRowBottom>
        </ListRowBody>
      </ListRow>
    </div>
  );
}
```

四种行组件共用一段配方片段 `listCell`（`packages/ui/src/lib/list-cell.ts`），所以它们不会彼此走样；装这些行的容器也共用同一个间隔和同一套内边距：

| 部分 | 数值 |
| --- | --- |
| 行 | 占满宽度，没有外边距，没有边框，透明。整行都是点击目标。它的高度不变：侧栏行高是 `row-height`，桌面上 34px（768px 起的触屏上 38px，手机上 44px）。 |
| 状态形状 | 行的 `::before`：和行一样高，离起始端和末端各 8px，`radius-control`（8px），在行的内容下面 |
| 两行之间的间隔 | 2px，即列表的 `gap`（`gap-0.5`），从不用外边距，也不靠形状的内缩。间隔是 4px 时，两行对话之间的空隙显得太大（设计评审，2026-10-26）。 |
| 列表的两端 | 上下各 8px 内边距（`py-2`），所以第一个形状在列表上边缘下方 8px，最后一个在下边缘上方 8px，和每个形状离两边的距离一样：圆角形状要从四边量，而不只是左右（设计评审，2026-10-26）。 |
| 容器 | `SidebarList`、`SearchResultsList` 和搜索里一个消息列的消息都是 `flex flex-col gap-0.5 py-2`；`ActivityInboxViewport` 是同样的列，它的下内边距再加上安全区域；`DialogNav` 也一样。其他任何单元格列表都是 `flex flex-col gap-0.5 py-2` 的列。 |
| 标题 → 第一行 | 在每个列表里，从标题文字的底部到第一个形状都是 8px：标题（侧栏的分区标题或分组标签、搜索的分区标题、搜索里消息列的标题）没有下内边距，列表自己的 8px 就是全部的间距。分区为空时的那一行（`SidebarSectionEmpty`）同样在标题下方 8px。 |
| 内容 | 以形状为基准留内边距，而不是以列表为基准：在形状里面，起始端和末端各 12px（`--cell-pad-x`），所以在手机和触屏上离列表边缘 20px（8px 内缩 + 12px）；桌面上用鼠标时在形状里面 8px，所以离列表边缘 16px，也就是头部开始的页面内边距（stdrc，2026-11-05）。开头的头像、图标和复选框从形状的内侧起点开始；末尾的时间、计数、@ 贴纸、静音标记和悬停时出现的按钮（Mark as Done）结束在形状的内侧终点。上下保留行自己的内边距：InboxItem、搜索结果和 ListRow 是 12px（`py-3`），桌面上用鼠标时 InboxItem 和搜索结果是 8px，让动态和搜索与桌面的密度一致；触屏上 InboxItem、搜索结果和默认 ListRow 是 8px、最小高 56px；侧栏行是行高，用鼠标时 34px，768px 起的触屏上 38px，它让 20px 高的那一行文字居中（7px、9px）。起止两端（inline 方向）的内边距由 `listCell` 设置，所以行的配方不再自己设。 |

## 同一条边
列表所在的列上方有一个头部，常常还有一条工具栏（Filter、Mark all as read、搜索的筛选条件），以及分区标题。它们都从同一条线开始，也就是行的内容开始的那条线：**在手机和触屏上离列的边缘 20px，桌面上用鼠标时 16px**。行把内容放在 8px（形状）+ 12px 处，用鼠标时 8px + 8px；列的边距跟着行走。

| 部分 | 离列的边缘 |
| --- | --- |
| 头部的标题，或头部开头的形状（工作空间按钮的头像、搜索框） | 20px（用鼠标时 16px） |
| 工具栏的第一个控件按它的形状算（带描边的 Filter 按钮），最后一个按它的文字或图标框算 | 20px（用鼠标时 16px） |
| 分区标题、概要、分区为空时的那一行、列表里的横幅 | 20px（用鼠标时 16px） |
| 行的状态形状 | 8px |
| 行开头的头像、图块或复选框，以及末尾的时间、计数和按钮 | 20px，在形状里面 12px（用鼠标时 16px，在形状里面 8px） |

手机上其他的列保留 16px（[对齐](https://raft-design.botiverse.build/zh/foundations/alignment.md#usage)）。列的边距是 `--column-margin` 变量，每个头部、面板提示和底部区域都按它留内边距：手机上 16px，768px 起 20px；由列表单元格组成的列把它设成行内容的起点：手机和触屏上 20px，桌面上用鼠标时 16px（`--page-inset`）（`ActivityInboxPanel`、`SidebarRoot`、`SearchShellRoot`）。这种列里应用自己的工具栏或横幅按 `px-(--column-margin)` 留内边距；应用自己的单元格列在根元素上设 `[--column-margin:20px] md:pointer-fine:[--column-margin:var(--page-inset)]`，和 raft-ui 自己的列一样；只设固定的 `[--column-margin:20px]` 会让用鼠标时头部在 20、行在 16。

为什么手机上不保留 16px：那样的话，要么行的内容只能放在形状里面 8px 处（有填充时内容就贴着形状，这正是本页要解决的问题），要么形状离屏幕边缘只有 4px，看起来像一条放错位置的通栏色带。整列用同一个边距，就能在任何宽度都保住 8px 的形状和它里面的 12px（设计评审，2026-10-27）。

## 列表行里的时间
说明某件事何时发生的行，把时间放在第一行（标题那一行）的末尾，别处都不放；它下面的行一直延伸到行的末端。在按天分组的列表里，只有今天的行，以及不到 1 小时的行，静止时显示时间。这些规则来自动态：那里的时间和任务编号占掉了行的大量宽度，把内容挤窄了（stdrc，2026-11-05，#proj-raft-ui:e09717fb：「时间不应该挤压内容」）。

- **时间只放在第一行。** 把时间和标记上下叠在一起的末尾列，在每一行上都和它最宽的部分一样宽，所以像 "2 hours ago" 这样的时间不光占了标题的宽度，也占了预览的宽度。放在第一行，时间只占标题的宽度；第二行保留整行的宽度，只减去它自己的标记。
- **行的状态标记放在第二行的末尾**：任务的状态标签、草稿标记、@ 贴纸、静音标记、未读数。第一行只放标题和时间。
- **在按天分组的列表里，只有今天的行，以及不到 1 小时的行，静止时显示时间**，用简短的相对时间，Space Mono、`text-mono-meta`、`foreground-hint`（[字体排印](https://raft-design.botiverse.build/zh/foundations/typography.md#type-scale)）。在今天之内，人们读的是隔了多久（它等了多久），而分组标题已经写着 Today。完整的格式：

  | 距今 | 显示 |
  | --- | --- |
  | 不到 1 分钟，不论哪一天 | now |
  | 1 到 59 分钟，不论哪一天 | 3m，分钟数 |
  | 1 小时及以上，仍在今天 | 2h，整小时数 |

  不写 "ago"，数字和单位之间没有空格，所以一个时间是 2 到 3 个字符。中文写作 刚刚、3 分钟 和 2 小时：用中文自己最短的单位，并按中文的规则在数字和汉字之间留空格。
- **不到 1 小时的行，即使在 Yesterday 下面也显示时间。** 刚过午夜时，6 分钟前的一行会落到 Yesterday 下面，不显示时间的话，它看起来就和昨天早上的行一样旧；它显示 6 分钟，直到满 1 小时，之后才按更早的行的规则处理（Jasper，2026-11-05，#proj-raft-ui:#196）。
- **更早的行只在悬停或键盘聚焦时显示时间。** 今天之前、已满 1 小时的行静止时不显示时间：它的日期标题已经说明了是哪天。指针停在这一行上，或者这一行或其中的控件持有键盘焦点时，时间在同一个位置出现，没有动画：钟点时间，按这个人选的 12 小时制或 24 小时制（17:20、5:20 PM；[写作](https://raft-design.botiverse.build/zh/content/writing.md#times)）。标题给出了日期，所以钟点就够了。每个更早的行都显示时间，会把分组省下的宽度又占回去。
- **在触屏上，更早的行的时间不在行上。** 触屏上没有悬停，所以日期标题就是这一行的日期，这一行打开的对话会显示每条消息的时间。长按菜单里放这一行的操作，不放时间，就像消息的菜单也没有日期行一样（[无障碍](https://raft-design.botiverse.build/zh/foundations/accessibility.md#touch)）。
- **在行的无障碍名称里用文字说出时间**，每一行都要，不管显示与否："2 hours ago"、"yesterday at 17:20"、"Tuesday at 9:05"。"2h" 读出来只是一个字母，而隐藏起来的时间也必须让看不到标题的人知道。
- **悬停操作出现时，让时间留在视野里。** 操作占据行的末端，时间和标记向左移动，距离是操作的宽度加 8px；它们不淡出、不隐藏（stdrc，2026-11-05）。人们在清掉一行之前查看它有多久了，这时指针正停在这一行上。在 InboxItem 里，这适用于用 `InboxItemLine` 搭的行；仍在用 `InboxItemTrailing` 的行，在改用行布局之前，操作显示时仍让时间和标记淡出（[InboxItem](https://raft-design.botiverse.build/zh/components/inbox-item.md#rules)）。
- **在触屏上，静止时就显示的操作和时间之间留 12px。** 触屏上 Done 静止时就显示，是墨色描边的小按钮，时间和标记离它 12px，而不是 8px：按钮 44px 的点击区域超出它 32px 的盒子每边 6px，所以留 8px 的话，点击区域和时间的文字之间只剩 2px，而点时间的文字会打开这一行。12px 也是触屏上两个操作之间的间距（[InboxItem](https://raft-design.botiverse.build/zh/components/inbox-item.md#behavior)；Jasper，2026-11-05，#proj-raft-ui:#196）。

## 按天分组的行
按时间排序、供人逐条处理的列表，比如动态（[Raft Chat](https://raft-design.botiverse.build/zh/products/raft-chat.md#the-bell-and-activity)），按天给它的行分组，最新的在前，每一天有一个标题（stdrc，2026-11-05，#proj-raft-ui:e09717fb，方案 C1）。

- **按对话里日期行的写法给日子命名**：Today、Yesterday，然后是星期和日期，Tuesday, October 20（[对话](https://raft-design.botiverse.build/zh/patterns/conversation.md#dates-and-grouping)），更早的年份加上年份：Tuesday, October 20, 2025。按这个人自己的时区、按这一行显示的时间来算日子。
- **把标题画成分区标题，而不是一行**：`text-footnote`，字重 700，和所有分组标题一样（[字体排印](https://raft-design.botiverse.build/zh/foundations/typography.md#sizes-by-use)），从行的内容线开始，离列表边缘 20px（用鼠标时 16px）（[同一条边](#one-edge)），从它的文字到第一行的形状 8px（[结构](#structure)）。它没有悬停、按下，也不是点击目标。
- **当前这一天的标题粘在列表顶部，它的行从它下面滚过**，标题在列表自己的表面上，没有哪一行会透出来；下一天的标题到达顶部时把它往上推出去。让每一天自成一组，用一个容器包住它的标题和它的行，标题就在这个容器里粘住：粘性标题只停留在自己的组里，所以组的末端到达顶部时，标题跟着离开，下一天的标题接替它，而不是叠在它上面；所有标题都粘在同一个列表里时就会叠在一起。更早的行静止时不显示时间，所以它的标题是屏幕上唯一说明它是哪一天的东西；标题滚出视野，这些行就没有日期了。
- **只给当前视图里有行的日子显示标题**：在筛选下，一天的行都没了，就没有那一天的标题。
- **把标题标记为列表标题下一级的标题**（动态的 `h2` 下的 `h3`），让读屏软件可以一天一天地跳。

## 规则
- **列表的两行之间不画线。** 两行之间的 2px、开头的图块和头像，以及分区标题下方的空间，已经把行分开了。穿过列表的线还会切开列表上的每一个圆角形状。
- **用分区标题和空间分开各组**，从不用线。标题从行的内容开始的地方开始（离列表边缘 20px，用鼠标时 16px），而不是从形状的边缘开始。
- **整行都是点击目标。** 行的打开按钮盖住整行（`InboxItemTrigger`），或者这一行本身就是按钮，所以一行上的任何一点，直到它的边缘，点下去都落在这一行上。两行之间 2px 的间隔不属于任何一行：点在那里会落在列表上。这是用列表的 `gap` 而不是在行内部留间距的代价，而且它窄到没法瞄准。
- **用列表的间隔隔开各行，两端各留 8px 内边距。** 把行直接放进一个 `gap-0.5 py-2` 的列里。给行加外边距，或者让形状从行的上下内缩，都会让每一行各自画出自己那一份间距。虚拟化列表的行是逐个定位的，`gap` 管不到单独定位的行，所以在虚拟化列表里改为把这 2px 加到虚拟列表的项间距上。
- **内容以形状为基准留内边距，从不以列表为基准。** 形状内缩 8px，而内容仍然离列表边缘 16px，就只剩 8px；一旦应用把形状再往里缩，就一点都不剩，末尾的标记和 Mark as Done 按钮就压在形状的边上（设计评审，2026-10-27：「悬停时对勾图标和未读计数贴到了边上」）。在形状里面，内容两侧都保持 12px（桌面上用鼠标时 8px），起始端和末端一样，并且在每种状态下都一样：静止、悬停（显示出操作）、按下、选中、被定位和获得焦点。行里的任何东西都不能伸进这 12px，只有部件画在自己盒子外面的一圈例外：贴纸 1.5px 的外圈（计数、@ 贴纸）和形状之间还留着 10.5px。给部件一个和它的内容一样宽的槽位（20px 的选择复选框占一个 20px 的槽位）。文字过长时在内侧的内边距处截断。这个数值和网页版侧栏的行一致，也就是设计评审指定的参照：它的形状离侧栏两边 8px，末尾的计数在形状里面 11.5px（现在是 12px，起始端也一样，起始端原来是 9.5px）。
- **头部、工具栏和标题从行的内容线开始。** 在触屏上，任何宽度都离列的边缘 20px，用鼠标时 16px（[同一条边](#one-edge)）。在 20px 的行上方放一条 16px 的工具栏，会留下两条相差 4px 的边，看起来像是弄错了。
- **不要从外面挪动形状或内容。** 应用给滚动容器加侧边内边距（`px-2`）、挪动形状的末端（`before:right-4`）或设置行的内边距（`px-2.5`），都会破坏这条规则：形状不再离列表 8px，或者内容不再在形状里面 12px。
- **所有状态都画在同一个形状上。** 行保持透明；悬停、按下、菜单打开、选中、被定位的高亮和键盘焦点都是填充这个形状或在它上面画环，从不铺满行的整个矩形：

  | 状态 | 形状上的填充 |
  | --- | --- |
  | 悬停 | `fill-cell-hover` |
  | 按下 | `fill-cell-press`，立即出现，不淡入 |
  | 菜单打开（右键菜单、长按） | `fill-cell-hover` |
  | 选中、在列表旁边打开（`active` 的 InboxItem、选中的 ListRow）、被定位 | `fill-cell-selected` |
  | 搜索里的键盘高亮 | `brand-soft` |
  | 侧栏里选中的行 | 黄色贴纸：`selected`，它 1.5px 的墨色边在形状里面，贴纸边缘围在外面 |
  | 键盘焦点 | `foreground` 色的 2px 内嵌环，在形状里面（在侧栏选中的行上，在贴纸的墨色边里面） |

- **深色模式下加强行的状态，只加强行的。** 浅色模式下，`fill-cell-hover`、`fill-cell-press` 和 `fill-cell-selected` 就是 `fill-subtle`、`fill-medium` 和 `info-soft`。深色模式下它们更强（7.5% 和 12% 的墨色，青色为 38%），因为在深色表面上，小圆角形状上 3% 的填充几乎看不出来。全局的档位不变：其他的悬停和按下状态仍然用它们（[状态](https://raft-design.botiverse.build/zh/foundations/states.md#hover-and-press)）。
- **触屏上的按下和鼠标一样。** 浏览器一把这一行标为按下（`:active`），手指的按下就在形状上显示 `fill-cell-press`，形状和颜色都和鼠标一样；只有悬停限定在能悬停的设备上。长按打开这一行的菜单后，菜单打开期间留下 `fill-cell-hover`。
- **选中或禁用的行不响应悬停或按下。** 它保留自己的填充。
- **不管滚动条怎样，形状都离列表两边 8px：单元格列表的滚动容器用 [ScrollArea](https://raft-design.botiverse.build/zh/components/scroll-area.md)。** 它的覆盖式滚动条不占空间，所以在每个平台上，不论显示滚动条与否，形状都离滚动容器的外边缘 8px。`ActivityInboxViewport`、`SearchShellViewport` 和 Sidebar 的 `SidebarContent` 都是 ScrollArea；在手机上 Sidebar 原生滚动，那里平台的滚动条也是覆盖式的。滚动条显示时，它的滑块盖住行形状的最后 2px（指针在它上面、滑块变宽时是 5px）：中号（medium）滑块停在离边缘 3px 处，宽 7px，被指向时 10px，所以它伸进列表 10px、被指向时 13px，而形状离边缘 8px。这是有意的：滑块只在滚动或被指向时显示，半透明，也不挪动任何东西，而且 Sidebar 的中号滑块本来就是这样放的；不要为了避开它给列表用小号（small）滑块（Jasper，2026-11-08，#proj-raft-ui:#209）。原生的经典滚动条出现时会从列表里占去自己的宽度，形状的末端也随之往里移这么多。没法做成 ScrollArea 的原生滚动容器，用 8px 的列表滚动条（`scrollbar-list`）和 `useScrollbarSize` 保住内缩：这个 hook 把滚动条占去的宽度发布为 `--scrollbar-size`，形状从末端的内缩里让出这么多。
- **把单元格列表直接放进它的滚动容器，不加侧边内边距。** 行自带内缩。滚动容器上的侧边内边距会叠加上去，还会在两侧各留下一条不属于任何一行的窄条。
- **行里悬停才出现的操作用幽灵按钮**（`variant="ghost"`；设了 `reveal="hover"` 的 `InboxItemActions`），所有行列表都一样。它们出现在已经显示悬停填充的行上，在填充上再加一圈墨色描边，就是两行文字里又多一条边；不带边框，按钮读起来就是这一行的一部分（stdrc，2026-11-05，#proj-raft-ui:e09717fb：「done 可以换成 ghost 按钮」）。它的盒子和每个末尾部件一样，结束在形状里面 12px 处：它的内边距不像栏末端的无边框按钮那样向外悬出（[视觉舒适](https://raft-design.botiverse.build/zh/foundations/comfort.md#edges)），因为在行里那会伸进这 12px。
- **悬停才出现的操作在行的高度上垂直居中**，而不是对齐第一行：它们作用于整行（stdrc，2026-11-05：「hover 上去 done 按钮肯定要垂直居中」）。它们显示时，旁边的行给它们让出位置（[列表行里的时间](#times-in-list-rows)）。
- **静止时就显示的操作仍用墨色描边的小按钮**：在触屏上（`reveal="hover"` 在那里让它们静止时就显示），以及在等着这个人处理的行里，比如 [NotificationCenter](https://raft-design.botiverse.build/zh/components/notification-center.md) 里的一条通知。周围没有悬停填充时，静止的无边框图标读起来像一个标记，而不是按钮（Jasper，2026-11-05，#proj-raft-ui:b4d8d24f）。

## 宽屏和窄屏
形状、内容在形状里面的 12px，以及列的 20px 边距，在触屏上任何宽度都一样；桌面上用鼠标时，内容在形状里面 8px，边距是 16px（[同一条边](#one-edge)）。在手机上，侧栏行为了手指增高到 44px，形状也随之增高；两行之间的 2px 不变。在手机上，形状同样离屏幕两边都是 8px：滚动条盖在列表上，不占空间（[规则](#rules)）。

- **行宽不到 360px 时，状态只用它的颜色标记表示。** 状态标签保留它的像素点，用状态的颜色，去掉文字；文字放进像素点上的工具提示和这一行的无障碍名称里。在这个宽度下，文字（"In progress"）要占掉第二行大约三分之一，而第二行已经被它的标记缩短了（stdrc，2026-11-05，#proj-raft-ui:e09717fb）。按行的宽度判断，而不是窗口的宽度：动态的列在任何屏幕上都能窄到 320px（[按区域的宽度切换](https://raft-design.botiverse.build/zh/patterns/responsive.md#switch-on-the-regions-width)）。工具提示在触屏上不会打开；在那里，文字仍在这一行的名称里，也在这一行打开的内容里。

## 相关
- [状态](https://raft-design.botiverse.build/zh/foundations/states.md#hover-and-press)：悬停和按下的档位，以及被定位的高亮。
- [形状](https://raft-design.botiverse.build/zh/foundations/shape.md#radii)：`radius-control`。
- [视觉舒适度](https://raft-design.botiverse.build/zh/foundations/comfort.md#separators)：哪些地方仍然画线。
- [InboxItem](https://raft-design.botiverse.build/zh/components/inbox-item.md)、[ListRow](https://raft-design.botiverse.build/zh/components/list-row.md)、[Sidebar](https://raft-design.botiverse.build/zh/components/sidebar.md)、[Search](https://raft-design.botiverse.build/zh/components/search.md)。
