improvement(toast): match notification styling with countdown ring and consistent design (#3688)

* improvement(toast): match notification styling with countdown ring and consistent design

* fix(toast): add success variant indicator dot
This commit is contained in:
Waleed
2026-03-19 23:41:01 -07:00
committed by GitHub
parent fa181f0155
commit 0be9303345

View File

@@ -18,6 +18,9 @@ const AUTO_DISMISS_MS = 0
const EXIT_ANIMATION_MS = 200
const MAX_VISIBLE = 20
const RING_RADIUS = 5.5
const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS
type ToastVariant = 'default' | 'success' | 'error'
interface ToastAction {
@@ -28,7 +31,6 @@ interface ToastAction {
interface ToastData {
id: string
message: string
description?: string
variant: ToastVariant
action?: ToastAction
duration: number
@@ -36,7 +38,6 @@ interface ToastData {
type ToastInput = {
message: string
description?: string
variant?: ToastVariant
action?: ToastAction
duration?: number
@@ -90,12 +91,31 @@ export function useToast() {
return ctx
}
const VARIANT_STYLES: Record<ToastVariant, string> = {
default: 'border-[var(--border)] bg-[var(--bg)] text-[var(--text-primary)]',
success:
'border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-800/40 dark:bg-emerald-950/30 dark:text-emerald-200',
error:
'border-red-200 bg-red-50 text-red-900 dark:border-red-800/40 dark:bg-red-950/30 dark:text-red-200',
function CountdownRing({ duration }: { duration: number }) {
return (
<svg
width='14'
height='14'
viewBox='0 0 16 16'
fill='none'
xmlns='http://www.w3.org/2000/svg'
style={{ transform: 'rotate(-90deg) scaleX(-1)' }}
>
<circle cx='8' cy='8' r={RING_RADIUS} stroke='var(--border)' strokeWidth='1.5' />
<circle
cx='8'
cy='8'
r={RING_RADIUS}
stroke='var(--text-icon)'
strokeWidth='1.5'
strokeLinecap='round'
strokeDasharray={RING_CIRCUMFERENCE}
style={{
animation: `notification-countdown ${duration}ms linear forwards`,
}}
/>
</svg>
)
}
function ToastItem({ toast: t, onDismiss }: { toast: ToastData; onDismiss: (id: string) => void }) {
@@ -117,38 +137,48 @@ function ToastItem({ toast: t, onDismiss }: { toast: ToastData; onDismiss: (id:
return (
<div
className={cn(
'pointer-events-auto flex w-[320px] items-start gap-[8px] rounded-[8px] border px-[12px] py-[10px] shadow-md transition-all',
VARIANT_STYLES[t.variant],
'pointer-events-auto w-[240px] overflow-hidden rounded-[8px] border border-[var(--border)] bg-[var(--bg)] shadow-sm',
exiting
? 'animate-[toast-exit_200ms_ease-in_forwards]'
: 'animate-[toast-enter_200ms_ease-out_forwards]'
)}
>
<div className='min-w-0 flex-1'>
<p className='font-medium text-[13px] leading-[18px]'>{t.message}</p>
{t.description && (
<p className='mt-[2px] text-[12px] leading-[16px] opacity-80'>{t.description}</p>
<div className='flex flex-col gap-[8px] p-[8px]'>
<div className='flex items-start gap-[8px]'>
<div className='line-clamp-2 min-w-0 flex-1 font-medium text-[12px] text-[var(--text-body)]'>
{t.variant === 'error' && (
<span className='mr-[8px] mb-[2px] inline-block h-[8px] w-[8px] rounded-[2px] bg-[var(--text-error)] align-middle' />
)}
{t.variant === 'success' && (
<span className='mr-[8px] mb-[2px] inline-block h-[8px] w-[8px] rounded-[2px] bg-[var(--text-success)] align-middle' />
)}
{t.message}
</div>
<div className='flex shrink-0 items-start gap-[2px]'>
{t.duration > 0 && <CountdownRing duration={t.duration} />}
<button
type='button'
onClick={dismiss}
aria-label='Dismiss notification'
className='-m-[2px] shrink-0 rounded-[5px] p-[4px] hover:bg-[var(--surface-active)]'
>
<X className='h-[14px] w-[14px] text-[var(--text-icon)]' />
</button>
</div>
</div>
{t.action && (
<button
type='button'
onClick={() => {
t.action!.onClick()
dismiss()
}}
className='w-full rounded-[5px] bg-[var(--surface-active)] px-[8px] py-[4px] font-medium text-[12px] hover:bg-[var(--surface-hover)]'
>
{t.action.label}
</button>
)}
</div>
{t.action && (
<button
type='button'
onClick={() => {
t.action!.onClick()
dismiss()
}}
className='shrink-0 font-medium text-[13px] underline underline-offset-2 opacity-90 hover:opacity-100'
>
{t.action.label}
</button>
)}
<button
type='button'
onClick={dismiss}
className='shrink-0 rounded-[4px] p-[2px] opacity-60 hover:opacity-100'
>
<X className='h-[14px] w-[14px]' />
</button>
</div>
)
}
@@ -175,7 +205,6 @@ export function ToastProvider({ children }: { children?: ReactNode }) {
const data: ToastData = {
id,
message: input.message,
description: input.description,
variant: input.variant ?? 'default',
action: input.action,
duration: input.duration ?? AUTO_DISMISS_MS,