fix(web): review-round attachment refinements

Body-portal the lightbox and toast so transformed ancestors cannot trap
their fixed positioning (a lightbox opened from a chat message covered only
the chat column); make the toast pointer-transparent; observe the rail
element's own size instead of window resizes; consume vertical wheel ticks
exclusively via a non-passive listener with LINE/PAGE delta normalization;
keep the start position when the rail mounts over an existing draft; honor
prefers-reduced-motion for the toast, remove-control, and paging; retry
loads through the guarded load effect; note the deliberate promptError
re-announce; pin the intake toast in the assembled snapshot; sync the
superseded multimodal note and package docs.
This commit is contained in:
creatixchu
2026-08-11 17:45:11 +08:00
parent e611e825b1
commit 87e3c95027
22 changed files with 218 additions and 73 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-attachment/README.md
README.md: a0a410532a631f243c3dfef7debfc6ce0bb0699e
README.zh.md: 4d8ae638a30dc37d89531f15cf3c541d8e09f8f6
README.md: 7e67a0064f5611ac814a9d489db0d4cc471309e1
README.zh.md: bf44f6a42db0e1a2c06e1f42133918150b123c2e

View File

@@ -6,7 +6,7 @@ Pure React attachment atoms (zero cordis): the composer draft-image rail (`Attac
## Attachment rail
`AttachmentRail` renders pending draft images as fixed 64px thumbnails (16px radius) in one horizontally scrolling row whose scrollbar stays hidden. Overflow is announced by circular edge arrows instead: each pages one viewport (minus one card of context, floored at 200px) with smooth scrolling, and arrow visibility is recomputed from scroll geometry on scroll, item-count changes, and window resizes. A vertical wheel pans the rail horizontally with per-tick travel clamped to 60px, while trackpad horizontal pans keep native scrolling. A newly added item is revealed at the rail's end; removal keeps the scroll position. Each thumbnail opens its original through `onOpen` on a single click, and its remove control sits inside the card's top-right corner, hidden until the card is hovered or the control keyboard-focused; coarse-pointer (touch) surfaces show it permanently because they have no hover. The owner decides mounting and renders the rail only while items exist.
`AttachmentRail` renders pending draft images as fixed 64px thumbnails (16px radius) in one horizontally scrolling row whose scrollbar stays hidden. Overflow is announced by circular edge arrows instead: each pages one viewport (minus one card of context, floored at 200px) with smooth scrolling (instant under `prefers-reduced-motion: reduce`), and arrow visibility is recomputed from scroll geometry on scroll, item-count changes, and rail size changes (a ResizeObserver on the rail element, so sidebar and panel resizes count, not only window resizes). A vertical wheel pans the rail horizontally through a non-passive listener that consumes the event — the same tick never also scrolls the conversation — with LINE/PAGE deltas normalized to pixels and per-tick travel clamped to 60px, while trackpad horizontal pans keep native scrolling. A newly added item is revealed at the rail's end; removal keeps the scroll position, and a rail that mounts over an already-populated draft keeps its start position. Each thumbnail opens its original through `onOpen` on a single click, and its remove control sits inside the card's top-right corner, hidden until the card is hovered or the control keyboard-focused; coarse-pointer (touch) surfaces show it permanently because they have no hover. The owner decides mounting and renders the rail only while items exist.
## Message images and the lightbox
@@ -24,3 +24,4 @@ None; this package neither assembles nor sends a provider request.
- **Images only** — non-image files have no rail card or history renderer yet; DeepSeek Chat-style file cards and upload-progress states wait until the composer accepts non-image attachments.
- **No zoom or download in the lightbox** — the preview renders the original at fit-to-viewport size only.
- **The lightbox does not trap focus** — it sets `aria-modal` and restores focus on close, but Tab can reach the page behind it (behavior carried over from the pre-package component).

View File

@@ -6,21 +6,22 @@
## 附件栏
`AttachmentRail` 将待发送草稿图片渲染为固定 64px16px 圆角)的缩略图横排,滚动条始终隐藏,溢出改由两端的圆形箭头提示:每次翻页滚动一个视口宽度(减去一张卡片作为上下文,下限 200px并平滑滚动箭头的显隐在滚动、条目数量变化和窗口尺寸变化时依据滚动几何重算。纵向滚轮转为横向平移,单次行程钳制在 60px 内,触控板的横向平移保持原生滚动。新增条目会滚动到栏尾展示,删除则保持原位。每张缩略图单击经 `onOpen` 打开原图,删除按钮位于卡片内部右上角,悬停卡片或键盘聚焦时才显示;粗指针(触屏)设备没有悬停,因此常显。是否挂载由持有方决定,仅在有条目时渲染。
`AttachmentRail` 将待发送草稿图片渲染为固定 64px16px 圆角)的缩略图横排,滚动条始终隐藏,溢出改由两端的圆形箭头提示:每次翻页滚动一个视口宽度(减去一张卡片作为上下文,下限 200px并平滑滚动`prefers-reduced-motion: reduce` 下瞬时完成),箭头的显隐在滚动、条目数量变化和栏自身尺寸变化时依据滚动几何重算rail 元素上的 ResizeObserver因此侧栏、面板的宽度变化也计入不只是窗口尺寸变化。纵向滚轮经非 passive 监听器转为横向平移并独占消费该事件同一次滚动不会同时滚动会话记录LINE/PAGE 单位的增量先归一化为像素,单次行程钳制在 60px 内,触控板的横向平移保持原生滚动。新增条目会滚动到栏尾展示,删除则保持原位,带着已有草稿重新挂载的栏保持起始位置。每张缩略图单击经 `onOpen` 打开原图,删除按钮位于卡片内部右上角,悬停卡片或键盘聚焦时才显示;粗指针(触屏)设备没有悬停,因此常显。是否挂载由持有方决定,仅在有条目时渲染。
## 消息图片与灯箱
`MessageImage` 渲染一张持久化历史图片,长边收敛到 240px经持有方的 `ImageLoader` 加载会话授权 URL加载失败渲染显式重试按钮加载完成后单击打开 `ImageLightbox`(加载中的点击被忽略)。`ImageGallery` 将一条消息的图片包为一个对齐的弹性分组(用户消息 `end`,助手消息 `start`),空列表不渲染。`ImageLightbox` 是文档级模态预览,按 Escape、按下遮罩或点关闭按钮均可关闭卸载时将焦点还给打开者。
## Model Experience
## 模型体验
None, as the package renders pure React atoms in the browser; nothing here reaches a model request.
无。该包package在浏览器中渲染纯 React 原子组件;这里没有任何内容进入模型请求。
#### KV Cache effect
#### KV Cache 影响
None; this package neither assembles nor sends a provider request.
无;该包既不组装也不发送提供方请求。
## Known Limitations and Deferred Work
## 已知限制与暂缓事项
- **仅支持图片** — 非图片文件尚无附件栏卡片与历史渲染DeepSeek Chat 风格的文件卡片和上传进度状态等输入框接受非图片附件后再做。
- **灯箱无缩放与下载** — 预览仅以适配视口的尺寸渲染原图。
- **灯箱不锁定焦点** — 它设置 `aria-modal` 并在关闭时归还焦点,但 Tab 仍可移动到背后的页面(沿袭入包前组件的行为)。

View File

@@ -30,12 +30,14 @@
"@deepseek-ai/dsh-attachment": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"clsx": "^2.0.0",
"react": "^18.2.0"
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/react": "~18.3.1"
"@types/react": "~18.3.1",
"@types/react-dom": "~18.3.0"
},
"files": [
"lib/index.js",

View File

@@ -81,6 +81,12 @@
}
}
@media (prefers-reduced-motion: reduce) {
.remove {
transition: none;
}
}
.arrow {
position: absolute;
top: 50%;

View File

@@ -2,7 +2,6 @@
* by edge arrows, hover-revealed per-item remove, single-click open. */
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import type { WheelEvent } from 'react'
import clsx from 'clsx'
import {
IconChevronLeftOutline14, IconChevronRightOutline14, IconCloseFill14,
@@ -33,15 +32,31 @@ export interface AttachmentRailLabels {
scrollRight: string
}
/** Approximate pixels per wheel step for `deltaMode` LINE deltas (Firefox
* notch wheels report lines, not pixels). */
const WHEEL_LINE_PX = 16
/** Smooth paging unless the user asked for reduced motion. */
function pageBehavior(): ScrollBehavior {
// jsdom (the unit lane) implements no matchMedia despite lib.dom's
// non-optional typing; the optional call keeps that lane on the default.
// oxlint-disable-next-line typescript/no-unnecessary-condition
return window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth'
}
/**
* Horizontal thumbnail rail over the caller's draft attachments.
*
* The rail scrolls with its scrollbar hidden; overflow is announced by edge
* arrows recomputed from scroll geometry on scroll, item-count changes, and
* window resizes. A vertical wheel pans horizontally, a newly added item is
* revealed at the rail's end, and each thumbnail opens on a single click while
* its remove control sits inside the card and reveals on hover or focus.
* The owner decides mounting; it renders the rail only while items exist.
* rail size changes (a ResizeObserver on the rail element, so sidebar or
* panel resizes count, not only window resizes). A vertical wheel pans the
* rail horizontally and is consumed exclusively (non-passive listener), a
* newly added item is revealed at the rail's end while a rail that mounts
* over an existing draft keeps its start position, and each thumbnail opens
* on a single click while its remove control sits inside the card and
* reveals on hover or focus. The owner decides mounting; it renders the rail
* only while items exist.
*
* @param props.items - resolved thumbnails in draft order.
* @param props.labels - rail-level strings (group name, open tooltip, arrows).
@@ -56,7 +71,10 @@ export function AttachmentRail<T extends AttachmentRailItem>({ items, labels, on
onRemove: (item: T) => void
}) {
const railRef = useRef<HTMLDivElement | null>(null)
const countRef = useRef(0)
// null marks the first layout pass: a rail that MOUNTS over an existing
// draft (session switch back to held images) is initial display, not
// growth, and must not jump to the end.
const countRef = useRef<number | null>(null)
const [edges, setEdges] = useState({ left: false, right: false })
const updateEdges = useCallback(() => {
const el = railRef.current
@@ -68,16 +86,51 @@ export function AttachmentRail<T extends AttachmentRailItem>({ items, labels, on
setEdges(prev => prev.left === left && prev.right === right ? prev : { left, right })
}, [])
useLayoutEffect(() => {
const grew = items.length > countRef.current
const grew = countRef.current !== null && items.length > countRef.current
countRef.current = items.length
const el = railRef.current
/* v8 ignore next -- defensive: the rail div renders unconditionally, so the layout effect always finds it. */
if (el === null) return
// A newly added attachment lands at the rail's end: reveal it.
if (grew && el !== null) el.scrollLeft = el.scrollWidth - el.clientWidth
if (grew) el.scrollLeft = el.scrollWidth - el.clientWidth
updateEdges()
}, [items.length, updateEdges])
useEffect(() => {
window.addEventListener('resize', updateEdges)
return () => { window.removeEventListener('resize', updateEdges) }
const el = railRef.current
/* v8 ignore next -- defensive: the rail div renders unconditionally, so the mount effect always finds it. */
if (el === null) return
// The rail's width follows the composer, which resizes with sidebars and
// panels, not only the window — observe the element itself. jsdom (the
// unit lane) implements no ResizeObserver; every browser gets the
// subscription.
let disconnect = (): void => {}
if (typeof ResizeObserver !== 'undefined') {
const observer = new ResizeObserver(updateEdges)
observer.observe(el)
disconnect = () => { observer.disconnect() }
}
// A vertical wheel pans the rail horizontally and is consumed: without
// preventDefault the same tick would also scroll the conversation behind
// the composer. React's root wheel listener is passive, so the exclusive
// conversion needs this manually attached non-passive listener. LINE and
// PAGE deltas (Firefox notch wheels) are normalized to pixels before the
// per-tick clamp that keeps a fast wheel followable.
const onWheel = (event: globalThis.WheelEvent): void => {
if (event.deltaX !== 0 || event.deltaY === 0) return
const scale = event.deltaMode === WheelEvent.DOM_DELTA_LINE
? WHEEL_LINE_PX
: event.deltaMode === WheelEvent.DOM_DELTA_PAGE ? el.clientWidth : 1
event.preventDefault()
el.scrollBy({
left: Math.sign(event.deltaY) * Math.min(Math.abs(event.deltaY) * scale, 60),
behavior: 'auto',
})
}
el.addEventListener('wheel', onWheel, { passive: false })
return () => {
disconnect()
el.removeEventListener('wheel', onWheel)
}
}, [updateEdges])
const page = (direction: -1 | 1): void => {
const el = railRef.current
@@ -85,16 +138,7 @@ export function AttachmentRail<T extends AttachmentRailItem>({ items, labels, on
if (el === null) return
// One viewport minus a card keeps the last visible thumbnail as context;
// the floor keeps narrow rails paging a useful distance.
el.scrollBy({ left: direction * Math.max(el.clientWidth - 64, 200), behavior: 'smooth' })
}
// A vertical wheel pans the rail horizontally (trackpads pan natively via
// deltaX); per-tick travel is clamped so a fast notch wheel stays followable.
const onWheel = (event: WheelEvent<HTMLDivElement>): void => {
if (event.deltaX !== 0 || event.deltaY === 0) return
event.currentTarget.scrollBy({
left: Math.sign(event.deltaY) * Math.min(Math.abs(event.deltaY), 60),
behavior: 'auto',
})
el.scrollBy({ left: direction * Math.max(el.clientWidth - 64, 200), behavior: pageBehavior() })
}
return (
<div className={css.root}>
@@ -114,7 +158,6 @@ export function AttachmentRail<T extends AttachmentRailItem>({ items, labels, on
role="group"
aria-label={labels.group}
onScroll={updateEdges}
onWheel={onWheel}
>
{items.map(item => (
<div key={item.id} className={css.item}>

View File

@@ -1,4 +1,5 @@
import { useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
import css from './ImageLightbox.module.css'
/** Lightbox strings the owner resolves from its own locale namespace. */
@@ -12,7 +13,9 @@ export interface ImageLightboxLabels {
/**
* Document-level original-image preview opened by clicking a thumbnail.
* Closes on Escape, backdrop press, or the close control, and restores focus
* to the opener on unmount.
* to the opener on unmount. Rendered through a body portal: an opener inside
* a transformed or filtered ancestor would otherwise trap the fixed backdrop
* in that ancestor's box instead of covering the viewport.
*
* @param props.src - the original image URL.
* @param props.alt - the image's alt text.
@@ -42,7 +45,7 @@ export function ImageLightbox({ src, alt, labels, onClose }: {
}
}, [onClose])
return (
return createPortal(
<div
className={css.backdrop}
role="dialog"
@@ -52,6 +55,7 @@ export function ImageLightbox({ src, alt, labels, onClose }: {
>
<img className={css.image} src={src} alt={alt} />
<button ref={closeRef} type="button" className={css.close} aria-label={labels.close} onClick={onClose}>×</button>
</div>
</div>,
document.body,
)
}

View File

@@ -40,24 +40,23 @@ export function MessageImage({ attachment, load, labels }: {
const [src, setSrc] = useState<string | null>(null)
const [error, setError] = useState(false)
const [open, setOpen] = useState(false)
// Retry re-arms the one load effect below, so every attempt — first load or
// retry — runs under the same liveness guard and the same reset.
const [attempt, setAttempt] = useState(0)
const request = useCallback(() => { setAttempt(a => a + 1) }, [])
const close = useCallback(() => { setOpen(false) }, [])
const size = useMemo(() => {
const scale = Math.min(1, 240 / attachment.width, 240 / attachment.height)
return { width: Math.max(1, Math.round(attachment.width * scale)), height: Math.max(1, Math.round(attachment.height * scale)) }
}, [attachment.height, attachment.width])
const request = useCallback(() => {
setError(false)
setSrc(null)
void load(attachment).then(setSrc).catch(() => { setError(true) })
}, [attachment, load])
useEffect(() => {
let live = true
setError(false)
setSrc(null)
void load(attachment).then((url) => { if (live) setSrc(url) }).catch(() => { if (live) setError(true) })
return () => { live = false }
}, [attachment, load])
}, [attachment, load, attempt])
const label = attachment.name ?? labels.image
if (error) return <button type="button" className={css.error} onClick={request}>{labels.loadFailed}</button>

View File

@@ -1,15 +1,32 @@
// @vitest-environment jsdom
// AttachmentRail behavior in the jsdom lane: item rendering and callbacks,
// arrow paging over stubbed scroll geometry (jsdom lays nothing out), the
// vertical-wheel pan, and the new-item end reveal.
// exclusive vertical-wheel pan, and the new-item end reveal.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import { AttachmentRail } from '../src/AttachmentRail.tsx'
import type { AttachmentRailItem, AttachmentRailLabels } from '../src/AttachmentRail.tsx'
afterEach(cleanup)
// jsdom implements no ResizeObserver; the stub records instances so a test
// can drive the size-change recompute path.
const observers: { callback: ResizeObserverCallback; observed: Element[] }[] = []
beforeEach(() => {
observers.length = 0
vi.stubGlobal('ResizeObserver', class {
observed: Element[] = []
constructor(callback: ResizeObserverCallback) {
observers.push({ callback, observed: this.observed })
}
observe(el: Element) { this.observed.push(el) }
disconnect() { this.observed.length = 0 }
})
})
afterEach(() => { vi.unstubAllGlobals() })
const labels: AttachmentRailLabels = {
group: '待发送图片',
open: '查看原图',
@@ -78,34 +95,58 @@ describe('AttachmentRail', () => {
expect(view.getByLabelText('向右滚动图片')).toBeTruthy()
})
it('shows both arrows mid-scroll and recomputes on window resize', () => {
it('shows both arrows mid-scroll and recomputes when the rail itself resizes', () => {
const view = render(
<AttachmentRail items={[item('a'), item('b'), item('c')]} labels={labels} onOpen={vi.fn()} onRemove={vi.fn()} />,
)
const rail = view.getByRole('group', { name: '待发送图片' })
const { setScrollLeft } = stubGeometry(rail, { scrollWidth: 400, clientWidth: 200 })
setScrollLeft(100)
fireEvent(window, new Event('resize'))
// The component observes the rail element, not the window: a sidebar or
// panel resize reaches it through the ResizeObserver callback.
expect(observers.at(-1)?.observed).toContain(rail)
act(() => { observers.at(-1)!.callback([], undefined as never) })
expect(view.getByLabelText('向左滚动图片')).toBeTruthy()
expect(view.getByLabelText('向右滚动图片')).toBeTruthy()
})
it('pans horizontally on a vertical wheel with clamped travel', () => {
it('pans horizontally on a vertical wheel, consuming the event, with clamped normalized travel', () => {
const view = render(
<AttachmentRail items={[item('a'), item('b')]} labels={labels} onOpen={vi.fn()} onRemove={vi.fn()} />,
)
const rail = view.getByRole('group', { name: '待发送图片' })
const { scrollBy } = stubGeometry(rail, { scrollWidth: 400, clientWidth: 200 })
fireEvent.wheel(rail, { deltaY: 30 })
// Converted ticks are consumed (preventDefault): fireEvent returns false.
expect(fireEvent.wheel(rail, { deltaY: 30 })).toBe(false)
expect(scrollBy).toHaveBeenCalledWith({ left: 30, behavior: 'auto' })
fireEvent.wheel(rail, { deltaY: 500 })
expect(scrollBy).toHaveBeenCalledWith({ left: 60, behavior: 'auto' })
fireEvent.wheel(rail, { deltaY: -500 })
expect(scrollBy).toHaveBeenCalledWith({ left: -60, behavior: 'auto' })
// Firefox notch wheels report lines; a page-mode wheel reports viewports.
fireEvent.wheel(rail, { deltaY: 2, deltaMode: WheelEvent.DOM_DELTA_LINE })
expect(scrollBy).toHaveBeenCalledWith({ left: 32, behavior: 'auto' })
fireEvent.wheel(rail, { deltaY: -1, deltaMode: WheelEvent.DOM_DELTA_PAGE })
expect(scrollBy).toHaveBeenCalledWith({ left: -60, behavior: 'auto' })
// A trackpad pan (deltaX) and a zero-delta wheel keep native behavior.
fireEvent.wheel(rail, { deltaX: 12, deltaY: 30 })
expect(fireEvent.wheel(rail, { deltaX: 12, deltaY: 30 })).toBe(true)
fireEvent.wheel(rail, { deltaY: 0 })
expect(scrollBy).toHaveBeenCalledTimes(3)
expect(scrollBy).toHaveBeenCalledTimes(5)
})
it('pages instantly under a reduced-motion preference, smoothly otherwise', () => {
for (const [matches, behavior] of [[true, 'auto'], [false, 'smooth']] as const) {
vi.stubGlobal('matchMedia', vi.fn(() => ({ matches }) as MediaQueryList))
const view = render(
<AttachmentRail items={[item('a'), item('b'), item('c')]} labels={labels} onOpen={vi.fn()} onRemove={vi.fn()} />,
)
const rail = view.getByRole('group', { name: '待发送图片' })
const { scrollBy } = stubGeometry(rail, { scrollWidth: 400, clientWidth: 200 })
fireEvent.scroll(rail)
fireEvent.click(view.getByLabelText('向右滚动图片'))
expect(scrollBy).toHaveBeenCalledWith({ left: 200, behavior })
view.unmount()
}
})
it('reveals the rail end when an item is added, not when one is removed', () => {

View File

@@ -1,5 +1,9 @@
import { clientOnly } from '../tsdown.client.ts'
// TODO(client-atoms): verbatim copy of ui-primitives/tsdown.config.ts (only
// the package differs). On a third atoms package, extract a shared css-stub
// client-library preset in packages/client/tsdown.client.ts instead of a
// fourth copy.
/**
* ui-attachment is browser-only, but its lib bundle IS imported under plain
* Node because the web shell is a lib (dsh-client-web's lib chain reaches

View File

@@ -82,7 +82,9 @@ export function InputBar({
const dismissToast = useCallback(() => { setToast(null) }, [])
// Prompt failures are ordinary failures (no create/attach transaction exists
// anymore): the toast announces promptError, the draft stays in the machine,
// and the user resubmits.
// and the user resubmits. A remount over a session whose machine still holds
// an unresolved promptError deliberately re-announces it once — the failure
// is still pending, and a transient banner is its only surface.
useEffect(() => {
if (promptError !== null) showToast(`${promptError.error.message} (${promptError.error.code})`)
}, [promptError, showToast])

View File

@@ -979,10 +979,12 @@ describe('strips and variants', () => {
vi.useFakeTimers()
try {
const send = bench({ promptError: { op: 'send', error: { code: 'agent-busy', message: 'boom', details: { reason: 'boom' } } } })
expect(send.view.container.querySelector('[role="alert"]')?.textContent).toContain('boom (agent-busy)')
// The toast body-portals (transformed ancestors must not trap it), so
// queries go through the view's document-bound helpers.
expect(send.view.getByRole('alert').textContent).toContain('boom (agent-busy)')
expect(send.view.queryByRole('button', { name: 'Retry' })).toBeNull()
act(() => { vi.advanceTimersByTime(4000) })
expect(send.view.container.querySelector('[role="alert"]')).toBeNull()
expect(send.view.queryByRole('alert')).toBeNull()
} finally {
vi.useRealTimers()
}

View File

@@ -11,6 +11,10 @@
/* Above the 1000 the image lightbox backdrop uses: a failure reported while
a preview is open must stay readable. */
z-index: 1100;
/* Purely an announcement: it must never intercept clicks — in particular
after the CSS fade finished while a throttled background-tab timer has
not yet unmounted the still-hit-testable fixed element. */
pointer-events: none;
display: flex;
align-items: center;
gap: 10px;
@@ -56,3 +60,11 @@
opacity: 0;
}
}
/* Reduced motion drops the slide-in; the delayed fade (an opacity change,
not movement) still ends the banner before the timed unmount. */
@media (prefers-reduced-motion: reduce) {
.toast {
animation: dsh-toast-fade 1000ms ease 3000ms forwards;
}
}

View File

@@ -1,5 +1,6 @@
import { useEffect } from 'react'
import type { ReactNode } from 'react'
import { createPortal } from 'react-dom'
import css from './Toast.module.css'
/** Full-opacity hold before the fade starts. Must agree with the stylesheet's
@@ -12,7 +13,9 @@ const FADE_MS = 1000
* Transient top-center banner: slides in, holds at full opacity, fades out,
* then reports done so the owner can unmount it. Re-showing the same text
* restarts the cycle when the owner remounts the component (key it by a
* per-show sequence).
* per-show sequence). Rendered through a body portal so an owner inside a
* transformed or filtered ancestor cannot trap the fixed banner in that
* ancestor's box.
*
* @param props.text - resolved banner copy; the owner passes localized text.
* @param props.icon - optional leading glyph (e.g. a warning icon).
@@ -28,10 +31,11 @@ export function Toast({ text, icon, onDone }: {
const timer = setTimeout(onDone, HOLD_MS + FADE_MS)
return () => { clearTimeout(timer) }
}, [onDone])
return (
return createPortal(
<div className={css.toast} role="alert">
{icon !== undefined && <span className={css.icon} aria-hidden>{icon}</span>}
<span className={css.text}>{text}</span>
</div>
</div>,
document.body,
)
}