'use client';/** * @title Snackbar * @description 화면 하단에 일시적으로 나타나 상태나 결과를 안내하는 컴포넌트예요. */import * as React from 'react';import { Toast as ToastPrimitive } from 'radix-ui';import { cva, type VariantProps } from 'class-variance-authority';import { cn } from '@/lib/utils';// SEED Rootage spec: snackbar.yamlconst snackbarRegionClassName = cn( 'pointer-events-none fixed inset-x-0 bottom-0 z-[2147483647] m-0 flex list-none flex-col items-center gap-x2 outline-none', 'px-[calc(var(--rb-dimension-x2)+env(safe-area-inset-left))] pt-x2 pb-[calc(var(--rb-dimension-x2)+env(safe-area-inset-bottom))]', 'transition-[bottom] duration-(--rb-duration-d4) ease-easing',);const snackbarVariants = cva( [ 'pointer-events-auto box-border flex w-full max-w-[464px] min-h-[44px] items-center rounded-r2 bg-bg-neutral-solid px-x2_5 py-x2_5 antialiased', 'outline-2 outline-offset-2 outline-transparent transition-[outline-color] focus-visible:outline-stroke-focus-ring', // enter: opacity 0, scale 0.8, d3, enter easing 'data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-80 data-[state=open]:duration-(--rb-duration-d3) data-[state=open]:ease-enter', // exit: opacity 0, scale 0.8, d2, exit easing 'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-80 data-[state=closed]:duration-(--rb-duration-d2) data-[state=closed]:ease-exit', // swipe down to dismiss 'data-[swipe=move]:translate-y-(--radix-toast-swipe-move-y) data-[swipe=cancel]:translate-y-0 data-[swipe=cancel]:transition-[translate]', 'data-[swipe=end]:translate-y-(--radix-toast-swipe-end-y)', ], { variants: { variant: { default: '[&_[data-slot=snackbar-prefix-icon]]:hidden', positive: '[&_[data-slot=snackbar-prefix-icon]]:text-fg-positive', critical: '[&_[data-slot=snackbar-prefix-icon]]:text-fg-critical', }, }, defaultVariants: { variant: 'default', }, },);type SnackbarVariant = NonNullable<VariantProps<typeof snackbarVariants>['variant']>;function CheckmarkCircleFillIcon(props: React.ComponentProps<'svg'>) { return ( <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden {...props}> <path fillRule="evenodd" clipRule="evenodd" d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Zm4.7 7.7a1 1 0 0 0-1.4-1.4l-4.8 4.8-1.8-1.8a1 1 0 0 0-1.4 1.4l2.5 2.5a1 1 0 0 0 1.4 0l5.5-5.5Z" /> </svg> );}function ExclamationmarkCircleFillIcon(props: React.ComponentProps<'svg'>) { return ( <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden {...props}> <path fillRule="evenodd" clipRule="evenodd" d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Zm0 5a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0V8a1 1 0 0 1 1-1Zm0 8.5a1.25 1.25 0 1 0 0 2.5 1.25 1.25 0 0 0 0-2.5Z" /> </svg> );}export interface SnackbarProps extends Omit<React.ComponentProps<typeof ToastPrimitive.Root>, 'children' | 'title'>, VariantProps<typeof snackbarVariants> { /** 스낵바에 표시할 메시지예요. */ message: React.ReactNode; /** 보조 액션 버튼의 라벨이에요. 1~2단어로 짧게 써요. */ actionLabel?: string; /** 액션 버튼을 눌렀을 때 호출돼요. 누르면 스낵바가 닫혀요. */ onAction?: () => void; /** 스크린 리더용 숨김 닫기 버튼 라벨이에요. */ closeLabel?: string;}/** 스낵바 한 개. 보통은 직접 쓰지 않고 `useSnackbar().create()` 로 띄워요. */function Snackbar({ className, variant = 'default', message, actionLabel, onAction, closeLabel = '닫기', ...props}: SnackbarProps) { return ( <ToastPrimitive.Root data-slot="snackbar" data-variant={variant} type={variant === 'critical' ? 'foreground' : 'background'} className={cn(snackbarVariants({ variant }), className)} {...props} > {variant !== 'default' && ( <span data-slot="snackbar-prefix-icon" className="flex size-[24px] shrink-0 pr-x0_5 [&_svg]:size-full"> {variant === 'positive' ? <CheckmarkCircleFillIcon /> : <ExclamationmarkCircleFillIcon />} </span> )} <div data-slot="snackbar-content" className="flex grow items-center justify-between gap-x2_5 px-x1_5"> <ToastPrimitive.Description data-slot="snackbar-message" className="m-0 text-t4 font-regular text-fg-on-neutral-solid" > {message} </ToastPrimitive.Description> {actionLabel && ( <ToastPrimitive.Action data-slot="snackbar-action-button" altText={actionLabel} onClick={onAction} className={cn( 'relative box-border inline-flex shrink-0 cursor-pointer select-none items-center justify-center border-none bg-transparent p-0 text-t4 font-bold text-fg-brand antialiased', 'transition-[scale] duration-(--rb-duration-pressed-scale) ease-(--rb-timing-function-pressed-scale) active:scale-[0.95]', // target size: 좌우 x2, 최소 높이 44px 의 터치 영역 'after:absolute after:top-1/2 after:-inset-x-x2 after:min-h-[44px] after:-translate-y-1/2 after:bg-transparent', 'outline-none after:outline-2 after:-outline-offset-2 after:outline-transparent after:transition-[outline-color]', 'focus-visible:after:rounded-r1 focus-visible:after:outline-stroke-focus-ring', )} > {actionLabel} </ToastPrimitive.Action> )} </div> <ToastPrimitive.Close data-slot="snackbar-hidden-close-button" className="sr-only"> {closeLabel} </ToastPrimitive.Close> </ToastPrimitive.Root> );}export type SnackbarStrategy = 'immediate' | 'queued';export interface CreateSnackbarOptions { message: React.ReactNode; variant?: SnackbarVariant; actionLabel?: string; onAction?: () => void; /** 표시 시간(ms). 기본값은 Provider 의 `duration` (4000)이에요. */ duration?: number; /** 이미 표시 중인 스낵바가 있을 때 즉시 교체(`immediate`)할지 큐에 넣을지(`queued`) 정해요. */ strategy?: SnackbarStrategy; /** 스낵바가 닫힐 때 호출돼요. */ onClose?: () => void;}interface SnackbarItem extends CreateSnackbarOptions { id: number;}interface SnackbarContextValue { create: (options: CreateSnackbarOptions) => number; dismiss: () => void;}const SnackbarContext = React.createContext<SnackbarContextValue | null>(null);/** 스낵바를 띄우고 닫는 함수를 돌려줘요. `SnackbarProvider` 안에서만 쓸 수 있어요. */function useSnackbar() { const context = React.useContext(SnackbarContext); if (!context) { throw new Error('useSnackbar 는 <SnackbarProvider> 안에서 사용해야 해요.'); } return context;}// exit 애니메이션(d2 = 200ms)이 끝난 뒤 다음 스낵바를 띄워요.const EXIT_DELAY = 200;export interface SnackbarProviderProps { children?: React.ReactNode; /** 기본 표시 시간(ms)이에요. */ duration?: number; /** 기본 표시 전략이에요. */ strategy?: SnackbarStrategy; /** 하단에서 띄울 거리예요. Floating Action Button, 하단 CTA 등과 겹치지 않게 할 때 써요. */ offset?: number | string; /** 영역(region)의 스크린 리더 라벨이에요. `{hotkey}` 는 단축키로 바뀌어요. */ label?: string; /** 영역 className 이에요. */ className?: string;}function SnackbarProvider({ children, duration = 4000, strategy = 'immediate', offset = 0, label = '알림 ({hotkey})', className,}: SnackbarProviderProps) { const [current, setCurrent] = React.useState<SnackbarItem | null>(null); const [open, setOpen] = React.useState(false); const currentRef = React.useRef<SnackbarItem | null>(null); const openRef = React.useRef(false); const queueRef = React.useRef<SnackbarItem[]>([]); const timerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null); const idRef = React.useRef(0); const show = React.useCallback((item: SnackbarItem | null) => { currentRef.current = item; openRef.current = item != null; setCurrent(item); setOpen(item != null); }, []); const close = React.useCallback(() => { const item = currentRef.current; if (!item || !openRef.current) return; openRef.current = false; setOpen(false); item.onClose?.(); if (timerRef.current) clearTimeout(timerRef.current); timerRef.current = setTimeout(() => { timerRef.current = null; show(queueRef.current.shift() ?? null); }, EXIT_DELAY); }, [show]); const create = React.useCallback( (options: CreateSnackbarOptions) => { const item: SnackbarItem = { ...options, id: ++idRef.current }; const itemStrategy = options.strategy ?? strategy; const busy = currentRef.current != null; if (busy && itemStrategy === 'queued') { queueRef.current.push(item); return item.id; } if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null; } if (openRef.current) currentRef.current?.onClose?.(); show(item); return item.id; }, [show, strategy], ); React.useEffect( () => () => { if (timerRef.current) clearTimeout(timerRef.current); }, [], ); const value = React.useMemo(() => ({ create, dismiss: close }), [create, close]); return ( <SnackbarContext.Provider value={value}> <ToastPrimitive.Provider duration={duration} swipeDirection="down" label="알림"> {children} {current && ( <Snackbar key={current.id} open={open} onOpenChange={(next) => { if (!next) close(); }} duration={current.duration} variant={current.variant} message={current.message} actionLabel={current.actionLabel} onAction={current.onAction} /> )} <ToastPrimitive.Viewport data-slot="snackbar-region" label={label} className={cn(snackbarRegionClassName, className)} style={{ bottom: offset }} /> </ToastPrimitive.Provider> </SnackbarContext.Provider> );}export { Snackbar, SnackbarProvider, useSnackbar, snackbarVariants };