feat(web): toast anchoring and model-selection rejection banner

The toast sits 120px from the viewport top and centers over its anchor —
the composer card, so it centers on the chat column rather than the window;
a rejected model selection (e.g. picking a text-only model while the
session holds images) announces through the same banner while the in-menu
strip with Retry stays the catalog-load surface. The attachment rail
consumes every wheel tick with a vertical component: a diagonal pan keeps
its horizontal intent and nothing scrolls the conversation behind the
composer.
This commit is contained in:
creatixchu
2026-08-11 18:00:56 +08:00
parent 87e3c95027
commit dc42e6b822
17 changed files with 158 additions and 42 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
README.md: 6e2cfed2578a59eec10f6d50b2bb5da3c7019764
README.zh.md: d651d9fac1c597b33ec95ffe9a0b69b3a69455a1
README.md: f96e931472a0946bd023b97b034643f079a67e44
README.zh.md: 0db2ac1bccbdc4793c488e1ce621ee5c674d9895

View File

@@ -10,7 +10,7 @@ Pure React atoms (zero cordis): StateDot, DisclosureRow, ic_ds_* icons, Button/P
## Toast
`Toast` is the transient top-center banner: it slides in, holds at full opacity for three seconds, fades over one second, then calls `onDone` so the owner can unmount it. It renders `role="alert"` with an optional leading icon slot and takes its copy as a required prop (zero-cordis: the owner localizes). Re-showing the same message requires a remount — owners key the element by a per-show sequence so an identical repeated message restarts the hold-and-fade cycle instead of silently reusing the faded banner. It layers above the ui-attachment image lightbox so a failure reported during a preview stays readable.
`Toast` is the transient top banner: it slides in, holds at full opacity for three seconds, fades over one second, then calls `onDone` so the owner can unmount it. It renders `role="alert"` with an optional leading icon slot and takes its copy as a required prop (zero-cordis: the owner localizes). It body-portals with `pointer-events: none`, sits 120px from the viewport top, and centers horizontally over the optional `anchor` element (re-measured on window resizes) — the composer passes its card so the banner centers over the chat column rather than the whole window — falling back to the viewport center without one. Re-showing the same message requires a remount — owners key the element by a per-show sequence so an identical repeated message restarts the hold-and-fade cycle instead of silently reusing the faded banner. Under `prefers-reduced-motion: reduce` the slide-in is dropped and only the delayed fade remains. It layers above the ui-attachment image lightbox so a failure reported during a preview stays readable.
## Markdown rendering

View File

@@ -10,7 +10,7 @@
## Toast
`Toast` 是顶部居中的短时横幅:滑入后满不透明度停留三秒,再用一秒淡出,随后调用 `onDone` 由持有方卸载。它渲染 `role="alert"`,带可选的前置图标插槽,文案是必填 prop零 cordis由持有方本地化。重复展示同一条消息需要重新挂载持有方用每次展示递增的序号作为 key让相同文案重新走完停留与淡出而不是静默复用已淡出的横幅。它的层级高于 ui-attachment 的图片灯箱,预览打开时报出的失败仍然可读。
`Toast` 是顶部的短时横幅:滑入后满不透明度停留三秒,再用一秒淡出,随后调用 `onDone` 由持有方卸载。它渲染 `role="alert"`,带可选的前置图标插槽,文案是必填 prop零 cordis由持有方本地化它经 body portal 渲染且 `pointer-events: none`,距视口顶部 120px水平中心跟随可选的 `anchor` 元素窗口尺寸变化时重测——composer 传入自己的卡片,横幅因此在聊天列而非整个窗口上居中——不传则回退到视口居中。重复展示同一条消息需要重新挂载,持有方用每次展示递增的序号作为 key让相同文案重新走完停留与淡出而不是静默复用已淡出的横幅。`prefers-reduced-motion: reduce` 下去掉滑入,只保留延迟淡出。它的层级高于 ui-attachment 的图片灯箱,预览打开时报出的失败仍然可读。
## Markdown 渲染

View File

@@ -6,7 +6,7 @@
.toast {
position: fixed;
top: 80px;
top: 120px;
left: 50%;
/* Above the 1000 the image lightbox backdrop uses: a failure reported while
a preview is open must stay readable. */

View File

@@ -1,4 +1,4 @@
import { useEffect } from 'react'
import { useEffect, useLayoutEffect, useState } from 'react'
import type { ReactNode } from 'react'
import { createPortal } from 'react-dom'
import css from './Toast.module.css'
@@ -19,20 +19,38 @@ const FADE_MS = 1000
*
* @param props.text - resolved banner copy; the owner passes localized text.
* @param props.icon - optional leading glyph (e.g. a warning icon).
* @param props.anchor - optional element whose horizontal center the banner
* follows (e.g. the composer card, so the banner centers over the chat column
* rather than the whole window); omitted, it centers on the viewport.
* @param props.onDone - called once the fade completes; unmount the toast here.
* @returns the floating banner.
*/
export function Toast({ text, icon, onDone }: {
export function Toast({ text, icon, anchor, onDone }: {
text: string
icon?: ReactNode
anchor?: HTMLElement | null
onDone: () => void
}) {
useEffect(() => {
const timer = setTimeout(onDone, HOLD_MS + FADE_MS)
return () => { clearTimeout(timer) }
}, [onDone])
// Anchor-centered placement re-measures on window resizes; the banner lives
// four seconds, so sub-window layout drift within that span stays out of
// scope.
const [left, setLeft] = useState<number | null>(null)
useLayoutEffect(() => {
if (anchor == null) return
const measure = (): void => {
const rect = anchor.getBoundingClientRect()
setLeft(rect.left + rect.width / 2)
}
measure()
window.addEventListener('resize', measure)
return () => { window.removeEventListener('resize', measure) }
}, [anchor])
return createPortal(
<div className={css.toast} role="alert">
<div className={css.toast} role="alert" style={left === null ? undefined : { left }}>
{icon !== undefined && <span className={css.icon} aria-hidden>{icon}</span>}
<span className={css.text}>{text}</span>
</div>,

View File

@@ -1,7 +1,7 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, render } from '@testing-library/react'
import { cleanup, fireEvent, render } from '@testing-library/react'
import { Toast } from '../src/Toast.tsx'
afterEach(cleanup)
@@ -24,6 +24,23 @@ describe('Toast', () => {
}
})
it('centers over its anchor and re-measures on window resize', () => {
vi.useFakeTimers()
try {
const anchor = document.createElement('div')
document.body.appendChild(anchor)
anchor.getBoundingClientRect = () => ({ left: 100, width: 400 }) as DOMRect
const view = render(<Toast text="anchored" anchor={anchor} onDone={vi.fn()} />)
expect(view.getByRole('alert').style.left).toBe('300px')
anchor.getBoundingClientRect = () => ({ left: 200, width: 400 }) as DOMRect
fireEvent(window, new Event('resize'))
expect(view.getByRole('alert').style.left).toBe('400px')
anchor.remove()
} finally {
vi.useRealTimers()
}
})
it('renders without an icon and cancels its timer on unmount', () => {
vi.useFakeTimers()
try {