模式
列表
列表的行怎么画:没有分隔线;悬停、按下、菜单打开、选中和焦点都画在同一个内缩的圆角形状上;内容在这个形状里面留 12px 内边距;列表用自己的间隔让行与行相隔 2px;滚动容器让形状两边的距离保持一致。
列表的一行是一个单元格(cell)。这一行横跨整个列表的宽度,它的各种状态画在行内的一个圆角形状上:和行一样高,离两边各内缩 8px。列表用它的 gap 让行与行相隔 2px,上下各留 8px 内边距,和形状离两边一样远,所以两行都有填充时,中间露出 2px 的列表,不用线就把两行分开了:列表没有分隔线。行的内容以形状为基准留内边距,两端都在形状里面 12px,所以行里没有任何东西碰到它的形状。
适用范围
所有由行组成、每一行都是一个可以打开或选取的东西的列表:
- 动态和通知:InboxItem。
- 侧栏,以及手机上用侧栏行画的地点列表:Sidebar。
- 搜索结果:Search(
SearchEntityResult、SearchMessageResult)。 - 手机上的地点列表和人的列表,以及进入其他页面的入口:带
cell的 ListRow。
不适用于:
- 表格:表格的行与行之间保留细线,因为它的列要横着读(Table)。
- 设置列表:设置卡片里的行(由
plainListRow 组成的ListRowGroup)保留分隔线和整行宽的填充。一张设置卡片是一张从上读到下的表单,线把每一项设置和下一项连在一起;在卡片自己的圆角边框里再放一个内缩的圆角形状,只会白白多画出第三道边(设计评审,2026-10-26)。 - 消息下方和侧边卡片里紧凑的任务行和文件行,以及任务页面的任务行,它们暂时保留自己的线(视觉舒适度;任务)。
- 弹出菜单和选项列表,它们的行遵循间距。
- 卡片列表(任务卡片、文件面板的卡片):每张卡片都是一个有自己边缘的对象,整张卡片都是点击目标,卡片之间的间隔保留。
结构
四种行组件共用一段配方片段 listCell(packages/ui/src/lib/list-cell.ts),所以它们不会彼此走样;装这些行的容器也共用同一个间隔和同一套内边距:
同一条边
列表所在的列上方有一个头部,常常还有一条工具栏(Filter、Mark all as read、搜索的筛选条件),以及分区标题。它们都从同一条线开始,也就是行的内容开始的那条线:在手机和触屏上离列的边缘 20px,桌面上用鼠标时 16px。行把内容放在 8px(形状)+ 12px 处,用鼠标时 8px + 8px;列的边距跟着行走。
手机上其他的列保留 16px(对齐)。列的边距是 --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(字体排印)。在今天之内,人们读的是隔了多久(它等了多久),而分组标题已经写着 Today。完整的格式:不写 "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;写作)。标题给出了日期,所以钟点就够了。每个更早的行都显示时间,会把分组省下的宽度又占回去。
-
在触屏上,更早的行的时间不在行上。 触屏上没有悬停,所以日期标题就是这一行的日期,这一行打开的对话会显示每条消息的时间。长按菜单里放这一行的操作,不放时间,就像消息的菜单也没有日期行一样(无障碍)。
-
在行的无障碍名称里用文字说出时间,每一行都要,不管显示与否:"2 hours ago"、"yesterday at 17:20"、"Tuesday at 9:05"。"2h" 读出来只是一个字母,而隐藏起来的时间也必须让看不到标题的人知道。
-
悬停操作出现时,让时间留在视野里。 操作占据行的末端,时间和标记向左移动,距离是操作的宽度加 8px;它们不淡出、不隐藏(stdrc,2026-11-05)。人们在清掉一行之前查看它有多久了,这时指针正停在这一行上。在 InboxItem 里,这适用于用
InboxItemLine搭的行;仍在用InboxItemTrailing的行,在改用行布局之前,操作显示时仍让时间和标记淡出(InboxItem)。 -
在触屏上,静止时就显示的操作和时间之间留 12px。 触屏上 Done 静止时就显示,是墨色描边的小按钮,时间和标记离它 12px,而不是 8px:按钮 44px 的点击区域超出它 32px 的盒子每边 6px,所以留 8px 的话,点击区域和时间的文字之间只剩 2px,而点时间的文字会打开这一行。12px 也是触屏上两个操作之间的间距(InboxItem;Jasper,2026-11-05,#proj-raft-ui:#196)。
按天分组的行
按时间排序、供人逐条处理的列表,比如动态(Raft Chat),按天给它的行分组,最新的在前,每一天有一个标题(stdrc,2026-11-05,#proj-raft-ui:e09717fb,方案 C1)。
- 按对话里日期行的写法给日子命名:Today、Yesterday,然后是星期和日期,Tuesday, October 20(对话),更早的年份加上年份:Tuesday, October 20, 2025。按这个人自己的时区、按这一行显示的时间来算日子。
- 把标题画成分区标题,而不是一行:
text-footnote,字重 700,和所有分组标题一样(字体排印),从行的内容线开始,离列表边缘 20px(用鼠标时 16px)(同一条边),从它的文字到第一行的形状 8px(结构)。它没有悬停、按下,也不是点击目标。 - 当前这一天的标题粘在列表顶部,它的行从它下面滚过,标题在列表自己的表面上,没有哪一行会透出来;下一天的标题到达顶部时把它往上推出去。让每一天自成一组,用一个容器包住它的标题和它的行,标题就在这个容器里粘住:粘性标题只停留在自己的组里,所以组的末端到达顶部时,标题跟着离开,下一天的标题接替它,而不是叠在它上面;所有标题都粘在同一个列表里时就会叠在一起。更早的行静止时不显示时间,所以它的标题是屏幕上唯一说明它是哪一天的东西;标题滚出视野,这些行就没有日期了。
- 只给当前视图里有行的日子显示标题:在筛选下,一天的行都没了,就没有那一天的标题。
- 把标题标记为列表标题下一级的标题(动态的
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(同一条边)。在 20px 的行上方放一条 16px 的工具栏,会留下两条相差 4px 的边,看起来像是弄错了。
-
不要从外面挪动形状或内容。 应用给滚动容器加侧边内边距(
px-2)、挪动形状的末端(before:right-4)或设置行的内边距(px-2.5),都会破坏这条规则:形状不再离列表 8px,或者内容不再在形状里面 12px。 -
所有状态都画在同一个形状上。 行保持透明;悬停、按下、菜单打开、选中、被定位的高亮和键盘焦点都是填充这个形状或在它上面画环,从不铺满行的整个矩形:
-
深色模式下加强行的状态,只加强行的。 浅色模式下,
fill-cell-hover、fill-cell-press和fill-cell-selected就是fill-subtle、fill-medium和info-soft。深色模式下它们更强(7.5% 和 12% 的墨色,青色为 38%),因为在深色表面上,小圆角形状上 3% 的填充几乎看不出来。全局的档位不变:其他的悬停和按下状态仍然用它们(状态)。 -
触屏上的按下和鼠标一样。 浏览器一把这一行标为按下(
:active),手指的按下就在形状上显示fill-cell-press,形状和颜色都和鼠标一样;只有悬停限定在能悬停的设备上。长按打开这一行的菜单后,菜单打开期间留下fill-cell-hover。 -
选中或禁用的行不响应悬停或按下。 它保留自己的填充。
-
不管滚动条怎样,形状都离列表两边 8px:单元格列表的滚动容器用 ScrollArea。 它的覆盖式滚动条不占空间,所以在每个平台上,不论显示滚动条与否,形状都离滚动容器的外边缘 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 处:它的内边距不像栏末端的无边框按钮那样向外悬出(视觉舒适),因为在行里那会伸进这 12px。 -
悬停才出现的操作在行的高度上垂直居中,而不是对齐第一行:它们作用于整行(stdrc,2026-11-05:「hover 上去 done 按钮肯定要垂直居中」)。它们显示时,旁边的行给它们让出位置(列表行里的时间)。
-
静止时就显示的操作仍用墨色描边的小按钮:在触屏上(
reveal="hover"在那里让它们静止时就显示),以及在等着这个人处理的行里,比如 NotificationCenter 里的一条通知。周围没有悬停填充时,静止的无边框图标读起来像一个标记,而不是按钮(Jasper,2026-11-05,#proj-raft-ui:b4d8d24f)。
宽屏和窄屏
形状、内容在形状里面的 12px,以及列的 20px 边距,在触屏上任何宽度都一样;桌面上用鼠标时,内容在形状里面 8px,边距是 16px(同一条边)。在手机上,侧栏行为了手指增高到 44px,形状也随之增高;两行之间的 2px 不变。在手机上,形状同样离屏幕两边都是 8px:滚动条盖在列表上,不占空间(规则)。
- 行宽不到 360px 时,状态只用它的颜色标记表示。 状态标签保留它的像素点,用状态的颜色,去掉文字;文字放进像素点上的工具提示和这一行的无障碍名称里。在这个宽度下,文字("In progress")要占掉第二行大约三分之一,而第二行已经被它的标记缩短了(stdrc,2026-11-05,#proj-raft-ui:e09717fb)。按行的宽度判断,而不是窗口的宽度:动态的列在任何屏幕上都能窄到 320px(按区域的宽度切换)。工具提示在触屏上不会打开;在那里,文字仍在这一行的名称里,也在这一行打开的内容里。