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.
60 lines
2.4 KiB
TypeScript
60 lines
2.4 KiB
TypeScript
import { useEffect, useLayoutEffect, useState } 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
|
|
* toast-fade delay (Toast.module.css) or the banner unmounts mid-fade. */
|
|
const HOLD_MS = 3000
|
|
/** Fade duration. Must agree with the stylesheet's toast-fade duration. */
|
|
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). 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).
|
|
* @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, 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" style={left === null ? undefined : { left }}>
|
|
{icon !== undefined && <span className={css.icon} aria-hidden>{icon}</span>}
|
|
<span className={css.text}>{text}</span>
|
|
</div>,
|
|
document.body,
|
|
)
|
|
}
|