跳转到内容

MessageFeed 消息流 alpha

一段会话的消息序列:粘底跟随、条目集合语义、键盘遍历与一个统一的播报区。

用法

消息内容全部由作者编写;组件管理的是集合语义、粘底与播报区

这个组件负责什么?
助手
集合语义、粘底跟随,以及一个统一的播报区。
气泡样式呢?
助手
气泡、头像、时间都由你自己写,按条目上的 data-role 出样式。

组件结构

加粗的是必需部件。

data-scope="message-feed"root · viewport · list · item · item-label · scroll-to-end-trigger · live-region

示例

粘底跟随与播报

新消息增长时自动到底部,向上翻即解除;一轮结束后在播报区朗读一句

第 1 条:往上翻一下,粘附会解除,右下角出现回到底部。

按角色分侧

条目上带 data-role,左右分侧与气泡在使用者一侧编写,组件不预设这层外观

帮我把上面那段改成三句话。
助手
好的,三句话的版本如下,保留了原来的结论顺序。
第二句再短一点。
助手
已经把第二句压到十二个字,其余不动。

运行态与播报

status 由宿主持有,组件只把它透出为 root 上的 data-state;播报只发生在 live-region 中,一轮结束后才写入一句

帮我写一段开场白。
status:idle

触底加载更多

stick-change 报告到达底部,宿主据此获取下一页;先向上翻一段再滚回底部,取回的消息接在后面

第 1 条 · 先往上翻一段,再滚回底部
第 2 条 · 先往上翻一段,再滚回底部
第 3 条 · 先往上翻一段,再滚回底部
第 4 条 · 先往上翻一段,再滚回底部
第 5 条 · 先往上翻一段,再滚回底部
第 6 条 · 先往上翻一段,再滚回底部
第 7 条 · 先往上翻一段,再滚回底部
第 8 条 · 先往上翻一段,再滚回底部
已加载 8 条 · 第 1 / 3 页

向上加载更早的消息

直接监听视口的滚动事件:滚到接近顶部时获取上一页,取回的消息插在最前面,正在阅读的位置不会被顶走

第 33 条 · 会话记录
第 34 条 · 会话记录
第 35 条 · 会话记录
第 36 条 · 会话记录
第 37 条 · 会话记录
第 38 条 · 会话记录
第 39 条 · 会话记录
第 40 条 · 会话记录
已加载 8 条 · 最早到第 33 条

跳到指定的一条

消息 id 就是锚点:Vue 侧使用 root 插槽提供的 scrollToItem / focusItem,自定义元素侧按同一个 id 取节点自行滚动

第 1 条 · 会话记录
第 2 条 · 会话记录
第 3 条 · 会话记录
第 4 条 · 会话记录
第 5 条 · 会话记录
第 6 条 · 会话记录
第 7 条 · 会话记录
第 8 条 · 会话记录
第 9 条 · 会话记录
第 10 条 · 会话记录
第 11 条 · 会话记录
第 12 条 · 会话记录
第 13 条 · 会话记录
第 14 条 · 会话记录
第 15 条 · 会话记录
第 16 条 · 会话记录
(还没跳过)

设计指引

何时使用

  • AI 对话或聊天界面的消息列表。
  • 内容从底部持续生长,需要始终跟随到底,但用户向上翻时不被拉回。

何时不用

  • 内容不分条、只是持续追加的输出(运行日志、命令回显)时,使用日志。两者的粘底、回到底部与播报区是同一套,差别只在是否需要条目集合语义与逐条遍历。
  • 只是一列静态卡片时,使用列表
  • 消息数以万计时,本组件不与虚拟滚动组合,键盘遍历要求条目都在活动 DOM 中;长会话请配合无限滚动分批加载并自行截断历史。

特性

  • 粘底跟随:内容增高时自动到底,用户上滚即解除,滚回底部阈值内自动恢复。向上插入历史消息时补偿滚动位置,视口不跳动。
  • “回到底部”只判断是否在底部,不判断粘附意图:粘附中但内容尚未追上时按钮不显示。
  • 整份消息列表只占一个 Tab 停靠位:PageDown / PageUp 在消息之间移动,Ctrl+End / Ctrl+Home 一步移到消息流之外(会话界面中通常是输入框)。
  • 消息内容全部由作者编写:气泡、头像、时间、动作条都不是本组件的部件。
  • 新生成的消息与出现的“回到底部”各带一段淡入位移;减弱动效由令牌层收敛,不需要另行关闭。
  • “回到底部”留空时皮肤绘制向下的字形,放入节点即替换为自定义图形。

组合

  • 正文使用流式正文,代码使用代码视图
  • 每条消息的动作条使用工具栏,复制使用剪贴板
  • 加载更早的消息使用无限滚动,必须把消息流的滚动容器交给它,否则它的提前量只对窗口视口生效。
  • 空会话使用空状态,并显式把它的 live 设为 off:它默认会成为活动区域,放在消息流中会与播报区冲突。
  • 需要左右分侧或气泡时,条目上带 data-roleuser / assistant / system),在自己的样式表中按它编写 align-self、底色、内衬与最大行宽,组件不预设这层外观。
  • 仍在流式写入的条目带 data-streaming,这是留给使用者的钩子:正文经流式正文渲染时,光标就是“仍在写入”的标记;正文不经它渲染时,可按该属性自行添加非遮蔽式的标记,例如前导色条或标签态。

最佳实践

  • 条目必须是内容层的直接子节点:向上插入历史消息时的滚动补偿只在直接子节点中选锚点,套一层壳或使用 display: contents 都会让补偿静默失效。
  • 一轮流式结束时把整段最终文本写入播报区,不每个 token 写一次。

反模式

  • 给每条消息各写 tabindex="0":两百条消息就是两百个 Tab 停靠位。
  • 在消息流内再套一层滚动容器:粘底逻辑只识别本组件的视口,套一层后失效。
  • data-streaming 把整条消息压暗或虚化:一轮流式可能持续数分钟,被遮盖的正是读者正在逐字阅读的内容。

API 参考

产物

自定义元素<xh-message-feed>
Vue 组件XhMessageFeedItem XhMessageFeedItemLabel XhMessageFeedList XhMessageFeedLiveRegion XhMessageFeedRoot XhMessageFeedScrollToEndTrigger XhMessageFeedViewport
组合式函数useMessageFeed
状态机messageFeedMachine
皮肤@xihan-ui/styles/message-feed.css

Props

属性类型必填说明
countnumber消息总数,由宿主声明,不从 DOM 统计;aria-setsize 取它。
statusMessageFeedStatus本轮的运行态,只写 data-state,状态机不读取它。
thresholdnumber距底部多少 px 视为在底部,默认使用贴底原语的默认值。
loopboolean到达首尾是否回绕,默认 false:会话是线性的。
sizeSize尺寸:sm / md / lg。
translationsPartial<MessageFeedTranslations>
onStickChange(details: MessageFeedStickChangeDetails) => void
onItemFocus(details: MessageFeedItemFocusDetails) => void

事件

自定义元素将载荷放在 detail;Vue 使用同名 emit。

事件载荷说明
stick-changeMessageFeedStickChangeDetails贴底状态变化;detail 为 { atBottom: boolean, sticking: boolean }
item-focusMessageFeedItemFocusDetails锚点变化;detail 为 { id: string | null }

插槽

仅列出带载荷的插槽。

Vue 组件插槽载荷说明
XhMessageFeedRootdefaultMessageFeedRootSlotProps

状态

公开状态写入 data-state

部件取值
rootprops.status
scroll-to-end-trigger'hidden' | 'visible'

以下名称仅用于内部状态机。

状态idle

事件STICK.CHANGE · SCROLL_TO_BOTTOM · ITEM.FOCUS · FEED.BLUR · PRESS.START · PRESS.END

判据canPress

connect API

getXxxProps() 返回对应部件的宿主属性。

成员类型说明
statusMessageFeedStatus
atBottomboolean
stickingboolean
focusedIdstring | nullroving tabindex 的锚点。
showScrollToEndTriggerboolean是否显示回到底部按钮:只判断是否在底部,不判断贴附意图。
scrollToBottom() => void
scrollToItem(id: string) => void把某条消息滚进可视区;该条不在 DOM 中时不做任何事。
focusItem(id: string) => void把焦点落到某条消息上;该条不在 DOM 中时不做任何事。
getRootProps() => T['element']
getViewportProps() => T['element']
getListProps() => T['element']
getItemProps(props: MessageFeedItemProps) => T['element']
getItemLabelProps(props: Pick<MessageFeedItemProps, 'id'>) => T['element']
getScrollToEndTriggerProps() => T['button']
getLiveRegionProps() => T['element']

无障碍

键盘

规格出处:W3C APG

按键生效条件行为
PageDown焦点在消息流内焦点移到下一条消息;到末条时按 loop 决定回绕还是不动
PageUp焦点在消息流内焦点移到上一条消息;到首条时按 loop 决定回绕还是不动
Control+End焦点在消息流内焦点移到消息流之后的第一个可聚焦元素,会话界面里通常是输入框
Control+Home焦点在消息流内焦点移到消息流之前的最后一个可聚焦元素
Tab焦点在消息流内外之间移动整份消息列表只占一个 Tab 停靠位:没有锚点时由根容器认领并把焦点转投给第一条,有锚点时那一条认领、根容器让位
ArrowUp / ArrowDown / Home / End焦点落在某条消息上组件不接管,浏览器滚动最近的可滚动祖先
Enter / Space焦点在回到底部按钮上滚回底部并恢复粘附(原生按钮激活)
Enter / Space按住回到底部按钮且视口不在底部按住期间 scroll-to-end-trigger 投影 data-pressed,与指针 :active 同一副按压面;抬起、失焦或回到底部(按钮收起)撤下

ARIA

以下属性由 connect 生成。

部件属性
listaria-labeltranslations?.feed
listrole'feed'
itemaria-labelundefined | itemLabel(item.index + 1, count ?? -1, item.role)
itemaria-labelledbyscope.partId('message-feed', item-label:${item.id}) | undefined
itemaria-posinsetitem.index + 1
itemaria-setsizeprops.count
itemrole'article'
scroll-to-end-triggeraria-labeltranslations?.scrollToBottom
live-regionaria-atomic'true'
live-regionaria-live'polite'
  • role=feedrole=article,带 aria-posinset / aria-setsize;总数由 count 声明,不从 DOM 计数,虚拟化或分页时 DOM 中的条数不等于会话长度。
  • 集合语义落在内容层而不是最外层:role=feed 只识别 role=article 的子节点,而播报区与回到底部按钮都是最外层的子节点。最外层只作为 Tab 停靠点与键盘宿主。
  • 播报使用独立的原子区域:一份会话只应有一个活动区域,每条消息各开一个会互相打断。
  • 消息流本身不发 aria-busy:它会压制同一棵子树内播报区的播报。

样式参考

皮肤

@xihan-ui/styles/message-feed.css 使用 [data-scope="message-feed"][data-part="root"] 部件选择器,位于 xihan.componentsxihan.motion 层。覆盖样式使用 xihan.overrides

数据属性

connect 生成;条件不成立时不输出无值属性。

部件属性
rootdata-sizeprops.size
rootdata-stateprops.status
itemdata-roleitem.role
itemdata-streaming''(条件成立时才出现)
scroll-to-end-triggerdata-pressed''(条件成立时才出现)
scroll-to-end-triggerdata-state'hidden' | 'visible'
scroll-to-end-triggerdata-xh-action-control''
scroll-to-end-triggerdata-xh-action-display'always'
scroll-to-end-triggerdata-xh-action-profile'floating'
scroll-to-end-triggerdata-xh-action-size'xs'
scroll-to-end-triggerdata-xh-action-variant'ghost'

CSS 变量

本组件公开覆盖槽由独立皮肤的实际消费位生成;默认来源、作用部件和状态均与 CSS 同源。

变量部件CSS 属性状态默认来源说明
--xh-message-feed-gaplistgapdefault--xh-_message-feed-gapmessage-feed 的 list 部件 gap 覆盖槽。
--xh-message-feed-icon-sizescroll-to-end-trigger--xh-icon-sizedefault--xh-_action-profile-glyph-sizemessage-feed 的 scroll-to-end-trigger 部件 --xh-icon-size 覆盖槽。
--xh-message-feed-item-gapitemgapdefault--xh-space-1message-feed 的 item 部件 gap 覆盖槽。
--xh-message-feed-item-radiusitemborder-radiusdefault--xh-shape-surfacemessage-feed 的 item 部件 border-radius 覆盖槽。
--xh-message-feed-label-fgitem-labelcolordefault--xh-fg-mutedmessage-feed 的 item-label 部件 color 覆盖槽。
--xh-message-feed-label-font-sizeitem-labelfont-sizedefault--xh-text-caption-sizemessage-feed 的 item-label 部件 font-size 覆盖槽。
--xh-message-feed-plistpaddingdefault--xh-_message-feed-pmessage-feed 的 list 部件 padding 覆盖槽。
--xh-message-feed-scroll-to-end-trigger-bgscroll-to-end-triggerbackground-colordefault--xh-material-frosted-bgmessage-feed 的 scroll-to-end-trigger 部件 background-color 覆盖槽。
--xh-message-feed-scroll-to-end-trigger-bg-hoverscroll-to-end-triggerbackground-colordisabled
hover
loading
not([data-disabled])
not([data-loading])
--xh-_action-variant-bg-hovermessage-feed 的 scroll-to-end-trigger 部件 background-color 覆盖槽。
--xh-message-feed-scroll-to-end-trigger-borderscroll-to-end-triggerborder
border-color
default
disabled
focus-visible
hover
is(:active, [data-pressed])
loading
not([data-disabled])
not([data-loading])
pressed
--xh-material-frosted-bordermessage-feed 的 scroll-to-end-trigger 部件 border、border-color 覆盖槽。
--xh-message-feed-scroll-to-end-trigger-fgscroll-to-end-triggercolordefault--xh-material-frosted-fgmessage-feed 的 scroll-to-end-trigger 部件 color 覆盖槽。
--xh-message-feed-scroll-to-end-trigger-insetscroll-to-end-triggerinset-block-end
inset-inline-end
default--xh-space-4message-feed 的 scroll-to-end-trigger 部件 inset-block-end、inset-inline-end 覆盖槽。
--xh-message-feed-scroll-to-end-trigger-radiusscroll-to-end-triggerborder-radiusdefault--xh-shape-circlemessage-feed 的 scroll-to-end-trigger 部件 border-radius 覆盖槽。
--xh-message-feed-scroll-to-end-trigger-shadowscroll-to-end-triggerbox-shadowdefault
disabled
hover
is(:active, [data-pressed])
loading
not([data-disabled])
not([data-loading])
pressed
--xh-material-frosted-shadowmessage-feed 的 scroll-to-end-trigger 部件 box-shadow 覆盖槽。
--xh-message-feed-scroll-to-end-trigger-sizescroll-to-end-triggerblock-size
inline-size
default
xh-action-profile=floating
--xh-_action-profile-visual-sizemessage-feed 的 scroll-to-end-trigger 部件 block-size、inline-size 覆盖槽。

动效

关键帧 xh-message-feed-button-in · xh-message-feed-item-in 随皮肤自带,不引用别处文件里的名字。时长与缓动读动效令牌,改令牌即改全局节奏。

系统开启减弱动效时由令牌层统一收敛,皮肤不另作判断。

RTL

皮肤用逻辑属性排布(inline-start 一族),dir="rtl" 下自动镜像。

Released under The MIT License