Merge remote-tracking branch 'origin/master' into xtr/sidebar-workspace-controls
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
/* Transient top-center banner (DeepSeek Chat toast look): contrast fill,
|
||||
inverted label, slide-in, then hold-and-fade. The fade delay/duration MUST
|
||||
agree with HOLD_MS/FADE_MS in Toast.tsx: the component unmounts at their
|
||||
sum, so a mismatched sheet either cuts the fade or leaves an invisible
|
||||
banner blocking nothing. */
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
top: 120px;
|
||||
left: 50%;
|
||||
/* 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;
|
||||
max-width: min(560px, calc(100vw - 48px));
|
||||
padding: 12px 16px;
|
||||
border-radius: 14px;
|
||||
background: var(--dsw-alias-button-contrast-fill);
|
||||
color: var(--dsw-alias-label-primary-inverted);
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
box-shadow: var(--dsw-shadow-lv3);
|
||||
transform: translateX(-50%);
|
||||
animation:
|
||||
dsh-toast-in 160ms ease-out,
|
||||
dsh-toast-fade 1000ms ease 3000ms forwards;
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: none;
|
||||
color: var(--dsw-alias-state-warn-label);
|
||||
}
|
||||
|
||||
.text {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@keyframes dsh-toast-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -6px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dsh-toast-fade {
|
||||
to {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
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,
|
||||
)
|
||||
}
|
||||
@@ -23,6 +23,7 @@ export { FishLogo } from './FishLogo.tsx'
|
||||
export { BrandWordmark } from './BrandWordmark.tsx'
|
||||
export { Tooltip } from './Tooltip.tsx'
|
||||
export type { TooltipSide } from './Tooltip.tsx'
|
||||
export { Toast } from './Toast.tsx'
|
||||
export { writeClipboard } from './clipboard.ts'
|
||||
export { JsonTree } from './JsonTree.tsx'
|
||||
export type { JsonTreeProps, JsonTreeLabels } from './JsonTree.tsx'
|
||||
|
||||
Reference in New Issue
Block a user