/** * @title Flag * @description 작업 결과·알림·확인 메시지를 화면 좌하단에 잠깐 띄우는 플래그와 플래그 그룹, useFlags 훅이에요. */import * as React from 'react';import { createPortal } from 'react-dom';import { ChevronDown, ChevronUp, CircleCheck, Info, OctagonAlert, TriangleAlert, X } from 'lucide-react';import { cn } from '@/lib/utils';import { IconButton } from '@/components/ui/button';// Atlassian @atlaskit/flag — flag, flag-actions, flag-group, auto-dismiss-flag, flags-provider 스펙export type FlagAppearance = 'error' | 'info' | 'normal' | 'success' | 'warning';export type FlagId = string | number;export type FlagActionType = { content: React.ReactNode; onClick?: (e: React.MouseEvent<HTMLElement>) => void; href?: string; target?: string; testId?: string;};export type FlagActionsType = FlagActionType[];const DEFAULT_APPEARANCE: FlagAppearance = 'normal';export const AUTO_DISMISS_SECONDS = 8;// flag-background-colorconst flagBackground: Record<FlagAppearance, string> = { error: 'bg-ds-background-danger-bold', info: 'bg-ds-background-neutral-bold', normal: 'bg-ds-surface-overlay', success: 'bg-ds-background-success-bold', warning: 'bg-ds-background-warning-bold',};// flag-text-color(-token)const flagText: Record<FlagAppearance, string> = { error: 'text-ds-text-inverse', info: 'text-ds-text-inverse', normal: 'text-ds-text', success: 'text-ds-text-inverse', warning: 'text-ds-text-warning-inverse',};// flag-icon-colorconst flagIconColor: Record<FlagAppearance, string> = { error: 'text-ds-icon-inverse', info: 'text-ds-icon-inverse', normal: 'text-ds-icon-subtle', success: 'text-ds-icon-inverse', warning: 'text-ds-icon-warning-inverse',};// flag-icon-glyphconst flagIconGlyph: Record<FlagAppearance, React.ElementType> = { error: OctagonAlert, info: Info, normal: Info, success: CircleCheck, warning: TriangleAlert,};// action-text-color + theme.actionBackgroundColorconst actionAppearance: Record<FlagAppearance, string> = { error: 'text-ds-text-inverse bg-ds-background-inverse-subtle hover:bg-ds-background-inverse-subtle-hovered active:bg-ds-background-inverse-subtle-pressed', info: 'text-ds-text-inverse bg-ds-background-inverse-subtle hover:bg-ds-background-inverse-subtle-hovered active:bg-ds-background-inverse-subtle-pressed', success: 'text-ds-text-inverse bg-ds-background-inverse-subtle hover:bg-ds-background-inverse-subtle-hovered active:bg-ds-background-inverse-subtle-pressed', warning: 'text-ds-text-warning-inverse bg-ds-background-inverse-subtle hover:bg-ds-background-inverse-subtle-hovered active:bg-ds-background-inverse-subtle-pressed', normal: 'text-ds-link',};/* ------------------------------------------------------------------------------------------------- * FlagGroup context — 그룹의 맨 위 플래그만 닫을 수 있어요. * -----------------------------------------------------------------------------------------------*/type FlagGroupContextValue = { onDismissed: (id: FlagId) => void; isDismissAllowed: boolean;};const defaultFlagGroupContext: FlagGroupContextValue = { onDismissed: () => {}, isDismissAllowed: false };const FlagGroupContext = React.createContext<FlagGroupContextValue>(defaultFlagGroupContext);const useFlagGroup = () => React.useContext(FlagGroupContext);/* ------------------------------------------------------------------------------------------------- * Flag * -----------------------------------------------------------------------------------------------*/export interface FlagProps { /** 렌더링·onDismissed 에 쓰이는 고유 id */ id: FlagId; /** 굵게 보이는 제목 */ title: React.ReactNode; /** 제목 아래 보조 내용 */ description?: React.ReactNode; /** normal 이 아니면 진한 배경(bold)이 되고 닫기 대신 펼치기 버튼이 생겨요. */ appearance?: FlagAppearance; /** 왼쪽 위 아이콘. 없으면 appearance 기본 아이콘을 써요. */ icon?: React.ReactNode; /** 하단 액션. normal 은 링크, bold 는 버튼 모양이에요. */ actions?: FlagActionsType; /** 닫기 버튼을 누르면 id 와 함께 호출 */ onDismissed?: (id: FlagId) => void; /** 제목 heading 레벨 */ headingLevel?: 1 | 2 | 3 | 4 | 5 | 6; /** 스크린 리더 알림을 늦출 ms (다른 알림과 겹칠 때) */ delayAnnouncement?: number; /** AutoDismissFlag 에서만 쓰여요. 자동으로 닫히기까지 초 (기본 8초) */ autoDismissSeconds?: number; onMouseOver?: React.MouseEventHandler; onMouseOut?: React.MouseEventHandler; onFocus?: React.FocusEventHandler<HTMLElement>; onBlur?: React.FocusEventHandler<HTMLElement>; testId?: string; className?: string;}function FlagDismissButton({ appearance, isBold, isExpanded, onClick, testId,}: { appearance: FlagAppearance; isBold: boolean; isExpanded: boolean; onClick: () => void; testId?: string;}) { const ButtonIcon = isBold ? (isExpanded ? ChevronUp : ChevronDown) : X; const label = isBold ? (isExpanded ? '접기' : '펼치기') : '닫기'; return ( <IconButton data-testid={testId && (isBold ? `${testId}-toggle` : `${testId}-dismiss`)} appearance="subtle" spacing="compact" label={label} aria-expanded={isBold ? isExpanded : undefined} icon={<ButtonIcon className="size-3" />} onClick={onClick} className={cn('shrink-0 [&_svg]:size-3', flagText[appearance])} /> );}function FlagExpander({ isExpanded, testId, children,}: { isExpanded: boolean; testId?: string; children: React.ReactNode;}) { // platform-dst-flag-collapse-animation-fix: max-height 150 ↔ 0 을 250ms 로 전환하고, // 접히는 동안에는 내용을 남겨 둔 채 페이드 아웃해 (ExitingPersistence) 접힘 애니메이션이 보이게 한다. const [isRendered, setIsRendered] = React.useState(isExpanded); React.useEffect(() => { if (isExpanded) { setIsRendered(true); return; } const t = window.setTimeout(() => setIsRendered(false), 250); return () => window.clearTimeout(t); }, [isExpanded]); return ( <div data-testid={testId && `${testId}-expander`} aria-hidden={!isExpanded} className={cn( 'w-full min-w-0 flex-[1_1_100%] transition-[max-height] duration-250 motion-reduce:transition-none', isExpanded ? 'max-h-[150px]' : 'max-h-0 overflow-hidden', )} > {(isExpanded || isRendered) && ( <div data-state={isExpanded ? 'open' : 'closed'} className={cn( 'flex flex-col gap-ds-100 motion-reduce:animate-none', isExpanded ? 'animate-in fade-in-0 [--tw-animation-duration:250ms]' : 'animate-out fade-out-0 fill-mode-forwards [--tw-animation-duration:250ms]', )} > {children} </div> )} </div> );}function FlagActions({ actions, appearance, testId,}: { actions: FlagActionsType; appearance: FlagAppearance; testId?: string;}) { if (!actions.length) return null; const isBold = appearance !== DEFAULT_APPEARANCE; return ( <span className={cn('block ds-font-body', !isBold && '-translate-x-[2px]')}> <span data-testid={testId && `${testId}-actions`} className="flex flex-wrap items-center gap-ds-100"> {actions.map((action, index) => { const cls = cn( 'inline-flex h-6 cursor-pointer items-center whitespace-nowrap rounded-ds-small py-ds-0 ds-font-body font-ds-medium no-underline', 'hover:underline hover:decoration-current hover:decoration-solid active:underline', 'outline-2 outline-offset-2 outline-transparent focus-visible:outline-ds-border-focused', isBold ? 'px-ds-100' : 'bg-transparent px-ds-0', actionAppearance[appearance], ); const node = action.href ? ( <a href={action.href} target={action.target} rel={action.target === '_blank' ? 'noopener noreferrer' : undefined} onClick={action.onClick} data-testid={action.testId} className={cls} > {action.content} </a> ) : ( <button type="button" onClick={action.onClick} data-testid={action.testId} className={cls}> {action.content} </button> ); return ( <React.Fragment key={index}> {!isBold && index > 0 && ( <span aria-hidden className="text-ds-text-subtlest"> · </span> )} {node} </React.Fragment> ); })} </span> </span> );}function Flag({ id, title, description, appearance = DEFAULT_APPEARANCE, icon, actions = [], onDismissed: onDismissedProp, headingLevel = 2, delayAnnouncement, onMouseOver, onMouseOut, onFocus, onBlur, testId, className,}: FlagProps) { const { onDismissed: onDismissedFromGroup, isDismissAllowed } = useFlagGroup(); const [isExpanded, setIsExpanded] = React.useState(false); const [isDelayToAnnounce, setIsDelayToAnnounce] = React.useState(false); const isBold = appearance !== DEFAULT_APPEARANCE; const onDismissed = React.useCallback(() => { onDismissedProp?.(id); onDismissedFromGroup(id); }, [id, onDismissedProp, onDismissedFromGroup]); React.useEffect(() => { if (isBold && isExpanded && !description && !actions.length) setIsExpanded(false); }, [actions.length, description, isBold, isExpanded]); React.useEffect(() => { if (!delayAnnouncement) return; const t = window.setTimeout(() => setIsDelayToAnnounce(true), delayAnnouncement); return () => window.clearTimeout(t); }, [delayAnnouncement]); const Heading = `h${headingLevel}` as 'h2'; const DefaultIcon = flagIconGlyph[appearance]; const isDismissable = isBold || isDismissAllowed; const shouldRenderGap = (!isBold && (description || actions.length)) || isExpanded; return ( <div role="alert" data-slot="flag" data-appearance={appearance} data-testid={testId} onMouseOver={onMouseOver} onMouseOut={onMouseOut} onFocus={onFocus} onBlur={onBlur} className={cn('w-full', className)} > {isDelayToAnnounce && ( <span className="sr-only"> {title} {description} </span> )} <div className={cn( 'relative z-[600] w-full overflow-hidden rounded-ds-large p-ds-200 shadow-ds-overlay transition-[background-color] duration-200', flagBackground[appearance], )} > <div className="flex items-start gap-ds-200"> <div data-testid={testId && `${testId}-icon-container`} className={cn( 'flex min-h-6 min-w-6 shrink-0 items-center justify-center [&>svg]:size-4', flagIconColor[appearance], )} > {icon ?? <DefaultIcon aria-hidden />} </div> <span className="min-w-0 grow transition-[gap] duration-300"> <div data-testid={testId && `${testId}-content-stack`} className={cn('flex flex-col', shouldRenderGap ? 'gap-ds-100' : 'gap-ds-0')} > <div className="flex items-stretch justify-between gap-ds-100"> <div className="pt-ds-050 pb-ds-025 [overflow-wrap:anywhere]"> <Heading className={cn('ds-font-heading-xsmall', flagText[appearance])}> <span aria-hidden={isDelayToAnnounce || undefined}>{title}</span> </Heading> </div> {isDismissable && !(isBold && !description && !actions.length) ? ( <FlagDismissButton appearance={appearance} isBold={isBold} isExpanded={isExpanded} testId={testId} onClick={isBold ? () => setIsExpanded((v) => !v) : () => isDismissAllowed && onDismissed()} /> ) : null} </div> <FlagExpander isExpanded={!isBold || isExpanded} testId={testId}> {description ? ( <div data-testid={testId && `${testId}-description`} aria-hidden={isDelayToAnnounce || undefined} className={cn( 'max-h-[75pt] overflow-auto ds-font-body [overflow-wrap:anywhere]', flagText[appearance], )} > {description} </div> ) : null} <FlagActions actions={actions} appearance={appearance} testId={testId} /> </FlagExpander> </div> </span> </div> </div> </div> );}/* ------------------------------------------------------------------------------------------------- * AutoDismissFlag — 8초 뒤 자동으로 닫혀요. 마우스를 올리거나 포커스하면 멈춰요. * -----------------------------------------------------------------------------------------------*/function AutoDismissFlag(props: FlagProps) { const { id, onDismissed: onDismissedProp, autoDismissSeconds = AUTO_DISMISS_SECONDS } = props; const { onDismissed: onDismissedFromGroup, isDismissAllowed } = useFlagGroup(); const timer = React.useRef<number | null>(null); const dismissFlag = React.useCallback(() => { if (!isDismissAllowed) return; onDismissedProp?.(id); onDismissedFromGroup(id); }, [id, isDismissAllowed, onDismissedProp, onDismissedFromGroup]); const stop = React.useCallback(() => { if (timer.current) { window.clearTimeout(timer.current); timer.current = null; } }, []); const start = React.useCallback(() => { if (!isDismissAllowed) return; stop(); timer.current = window.setTimeout(dismissFlag, autoDismissSeconds * 1000); }, [isDismissAllowed, stop, dismissFlag, autoDismissSeconds]); React.useEffect(() => { start(); return stop; }, [start, stop]); return <Flag {...props} onMouseOver={stop} onFocus={stop} onMouseOut={start} onBlur={start} />;}/* ------------------------------------------------------------------------------------------------- * FlagGroup — 화면 좌하단 고정, 새 플래그가 맨 위. 맨 위 플래그만 닫을 수 있어요. * -----------------------------------------------------------------------------------------------*/export interface FlagGroupProps { id?: string; /** 스크린 리더용 그룹 제목 */ label?: string; /** 그룹 제목 태그 */ labelTag?: React.ElementType; /** 포털 대신 부모 안에 렌더링 */ shouldRenderToParent?: boolean; /** Flag / AutoDismissFlag 들. 첫 번째가 맨 위에 보여요. */ children?: React.ReactNode; /** 맨 위 플래그가 닫힐 때 id 와 함께 호출 */ onDismissed?: (id: FlagId) => void;}const EXIT_MS = 200;type ExitingFlag = { key: React.Key; element: React.ReactElement };const slotBase = 'absolute bottom-0 w-[400px] max-[560px]:w-screen [transition:translate_250ms_cubic-bezier(0.4,0,0,1)]';const slotPosition = (index: number) => index === 0 ? 'translate-y-0 z-[5]' : index === 1 ? 'translate-y-[calc(100%+16px)] z-[4]' : cn('translate-y-[calc(200%+32px)]', index >= 3 && 'invisible');const enterAnimation = 'animate-in fade-in-0 slide-in-from-left-[50%] [--tw-animation-duration:250ms] [--tw-ease:cubic-bezier(0,0.4,0,1)]';const exitAnimation = 'pointer-events-none z-[6] animate-out fade-out-0 slide-out-to-left-[15%] fill-mode-forwards [--tw-animation-duration:200ms] [--tw-ease:cubic-bezier(0.6,0,0.8,0.6)]';function FlagGroup({ id, label = '플래그 알림', labelTag: LabelTag = 'h2', shouldRenderToParent = false, children, onDismissed,}: FlagGroupProps) { const flags = React.Children.toArray(children).filter(React.isValidElement) as React.ReactElement[]; const hasFlags = flags.length > 0; const onDismissedRef = React.useRef(onDismissed); onDismissedRef.current = onDismissed; const dismissContext = React.useMemo<FlagGroupContextValue>( () => ({ onDismissed: (fid) => onDismissedRef.current?.(fid), isDismissAllowed: true }), [], ); // 처음부터 있던 플래그는 등장 애니메이션 없이 (ExitingPersistence appear=false) const initialKeys = React.useRef<Set<React.Key> | null>(null); if (initialKeys.current === null) initialKeys.current = new Set(flags.map((f) => f.key ?? '')); // 맨 위에서 사라진 플래그는 잠깐 남겨 퇴장 애니메이션을 보여줘요. const [exiting, setExiting] = React.useState<ExitingFlag[]>([]); const prevFlags = React.useRef<React.ReactElement[]>(flags); const timers = React.useRef<number[]>([]); const keySignature = flags.map((f) => String(f.key)).join('|'); React.useLayoutEffect(() => { const prev = prevFlags.current; const currentKeys = new Set(flags.map((f) => f.key)); const top = prev[0]; if (top && !currentKeys.has(top.key)) { const key = top.key ?? `exit-${Date.now()}`; setExiting((list) => [...list.filter((e) => e.key !== key), { key, element: top }]); const t = window.setTimeout(() => { setExiting((list) => list.filter((e) => e.key !== key)); }, EXIT_MS); timers.current.push(t); } prevFlags.current = flags; // eslint-disable-next-line react-hooks/exhaustive-deps }, [keySignature]); React.useEffect(() => () => timers.current.forEach((t) => window.clearTimeout(t)), []); // Escape 로 맨 위 플래그 닫기 const latestFlags = React.useRef(flags); latestFlags.current = flags; React.useEffect(() => { if (!hasFlags) return; const onKeyDown = (event: KeyboardEvent) => { if (event.key !== 'Escape' || event.defaultPrevented) return; const first = latestFlags.current[0]; const fid = (first?.props as { id?: FlagId } | undefined)?.id; if (fid === undefined || fid === null || fid === '') return; window.setTimeout(() => { if (event.defaultPrevented) return; onDismissedRef.current?.(fid); }); }; document.addEventListener('keydown', onKeyDown, true); return () => document.removeEventListener('keydown', onKeyDown, true); }, [hasFlags]); const [mounted, setMounted] = React.useState(false); React.useEffect(() => setMounted(true), []); const content = ( <div id={id} data-slot="flag-group" className="fixed bottom-ds-600 left-ds-1000 z-[600] max-[560px]:bottom-0 max-[560px]:left-0" > {hasFlags ? ( <span className="sr-only"> <LabelTag>{label}. Esc 키로 맨 위 알림을 닫을 수 있어요.</LabelTag> </span> ) : null} {exiting.map((item) => ( <div key={`exiting-${String(item.key)}`} aria-hidden className={cn(slotBase, 'translate-y-0', exitAnimation)}> <FlagGroupContext.Provider value={defaultFlagGroupContext}>{item.element}</FlagGroupContext.Provider> </div> ))} {flags.map((flag, index) => ( <div key={flag.key} data-slot="flag-group-item" className={cn( slotBase, slotPosition(index), !initialKeys.current?.has(flag.key ?? '') && enterAnimation, )} > <FlagGroupContext.Provider value={index === 0 ? dismissContext : defaultFlagGroupContext}> {flag} </FlagGroupContext.Provider> </div> ))} </div> ); if (shouldRenderToParent) return content; if (!mounted) return null; return createPortal(content, document.body);}/* ------------------------------------------------------------------------------------------------- * FlagsProvider + useFlags — showFlag 로 어디서든 플래그를 띄워요. * -----------------------------------------------------------------------------------------------*/export interface CreateFlagArgs extends Omit<FlagProps, 'id'> { /** 없으면 자동 생성. 같은 id 로 다시 보내면 교체돼요. */ id?: FlagId; /** true 면 AutoDismissFlag 로 렌더링 */ isAutoDismiss?: boolean;}export type DismissFn = () => void;export type FlagAPI = { showFlag: (args: CreateFlagArgs) => DismissFn; hideFlag: (id: FlagId) => void;};const FlagContext = React.createContext<FlagAPI | null>(null);let flagUid = 0;const getUniqueId = () => `flag-provider-unique-id:${flagUid++}`;function FlagsProvider({ children, shouldRenderToParent }: { children?: React.ReactNode; shouldRenderToParent?: boolean }) { const [flags, setFlags] = React.useState<(CreateFlagArgs & { id: FlagId })[]>([]); const removeFlag = React.useCallback((id: FlagId) => { setFlags((current) => current.filter((flag) => flag.id !== id)); }, []); const api = React.useMemo<FlagAPI>( () => ({ showFlag(value) { const flag = { ...value, id: value.id ?? getUniqueId() }; setFlags((current) => { const index = current.findIndex((f) => f.id === flag.id); if (index === -1) return [flag, ...current]; const next = [...current]; next[index] = flag; return next; }); return () => removeFlag(flag.id); }, hideFlag: removeFlag, }), [removeFlag], ); return ( <> <FlagContext.Provider value={api}>{children}</FlagContext.Provider> <FlagGroup onDismissed={removeFlag} shouldRenderToParent={shouldRenderToParent}> {flags.map(({ isAutoDismiss, ...rest }) => { const FlagType = isAutoDismiss ? AutoDismissFlag : Flag; return <FlagType key={rest.id} {...rest} />; })} </FlagGroup> </> );}/** FlagsProvider 안에서 showFlag / hideFlag 를 꺼내 써요. */function useFlags(): FlagAPI { const api = React.useContext(FlagContext); if (api == null) throw new Error('Unable to find FlagProviderContext'); return api;}function withFlagsProvider<P extends object>(Component: React.ComponentType<P>) { function WithFlagsProvider(props: P) { return ( <FlagsProvider> <Component {...props} /> </FlagsProvider> ); } return WithFlagsProvider;}export { Flag, AutoDismissFlag, FlagGroup, FlagsProvider, FlagContext, useFlags, withFlagsProvider };