/** * @title Comment * @description 이슈·페이지·풀 리퀘스트 같은 대상에 대해 의견을 나누는 댓글이에요. */import * as React from 'react';import { Lock, TriangleAlert } from 'lucide-react';import { cn } from '@/lib/utils';// Atlassian @atlaskit/comment — components/*.js 스펙/* ------------------------------------------------------------------------------------------------- * CommentLayout * -----------------------------------------------------------------------------------------------*/export interface CommentLayoutProps { /** 왼쪽 아바타 */ avatar?: React.ReactNode; /** 오른쪽 본문 영역 */ content?: React.ReactNode; /** 중첩 댓글 */ children?: React.ReactNode; /** 링크로 들어온 댓글처럼 강조할 때 배경을 깔아요. */ highlighted?: boolean; id?: string; /** 중첩 댓글을 아바타 아래부터(들여쓰기 없이) 그려요. 좁은 영역에 써요. */ shouldRenderNestedCommentsInline?: boolean; testId?: string; className?: string;}/** 댓글의 기본 레이아웃 (아바타 | 본문, 아래에 중첩 댓글) */function CommentLayout({ avatar, content, children, highlighted, id, shouldRenderNestedCommentsInline, testId, className,}: CommentLayoutProps) { return ( <div id={id} data-slot="comment-layout" data-testid={testId} className={cn('relative grid grid-cols-[auto_1fr] gap-ds-100', className)} > {avatar && ( <div data-slot="comment-avatar" className="col-start-1 row-start-1"> {avatar} </div> )} {content && ( <div data-slot="comment-content" className="col-start-2 row-start-1 min-w-0 pt-ds-025 [overflow-wrap:break-word]" > {content} </div> )} {children && ( <div data-slot="comment-nested" className={cn( 'row-start-2 flex flex-col gap-ds-400 pt-ds-300', shouldRenderNestedCommentsInline ? 'col-span-2 col-start-1' : 'col-start-2', )} > {children} </div> )} {highlighted && ( <div aria-hidden data-slot="comment-highlight" data-testid={testId && `${testId}-highlighted`} className="pointer-events-none absolute -inset-ds-100 col-start-1 col-end-3 row-start-1 row-end-2 bg-ds-background-neutral p-ds-100" /> )} </div> );}/* ------------------------------------------------------------------------------------------------- * 헤더 조각: Lozenge(type), CommentAuthor, CommentTime, CommentEdited * -----------------------------------------------------------------------------------------------*/// @atlaskit/lozenge defaultfunction TypeLozenge({ children, testId }: { children: React.ReactNode; testId?: string }) { return ( <span data-testid={testId} className="box-border inline-flex h-min max-w-full overflow-hidden rounded-ds-small bg-ds-background-neutral px-ds-050 align-middle" > <span className="truncate text-[11px] leading-4 font-ds-bold tracking-[0.165px] text-ds-text-subtle uppercase"> {children} </span> </span> );}interface CommentFieldProps { children?: React.ReactNode; href?: string; onClick?: React.MouseEventHandler<HTMLElement>; onFocus?: React.FocusEventHandler<HTMLElement>; onMouseOver?: React.MouseEventHandler<HTMLElement>; testId?: string;}function CommentField({ children, href, onClick, onFocus, onMouseOver, testId, hasAuthor }: CommentFieldProps & { hasAuthor?: boolean }) { const base = cn('text-ds-text-subtle', hasAuthor ? 'font-ds-medium' : 'font-[inherit]'); if (href) { return ( <a href={href} onClick={onClick} onFocus={onFocus} onMouseOver={onMouseOver} data-testid={testId} className={cn( base, 'rounded-ds-small no-underline hover:text-ds-link hover:underline active:text-ds-link-pressed active:no-underline', 'outline-2 outline-offset-2 outline-transparent focus-visible:outline-ds-border-focused', )} > {children} </a> ); } return ( <span role="presentation" onClick={onClick} onFocus={onFocus} onMouseOver={onMouseOver} data-testid={testId} className={base}> {children} </span> );}export type AuthorProps = CommentFieldProps;/** 작성자 이름. href 를 주면 링크가 돼요. */function CommentAuthor(props: AuthorProps) { return <CommentField hasAuthor {...props} />;}export type CommentTimeProps = CommentFieldProps;/** 작성 시각. href 를 주면 댓글 고유 링크가 돼요. */function CommentTime(props: CommentTimeProps) { return <CommentField {...props} />;}export interface EditedProps { children?: React.ReactNode; onFocus?: React.FocusEventHandler<HTMLSpanElement>; onMouseOver?: React.MouseEventHandler<HTMLSpanElement>;}/** "수정됨" 표시 */function CommentEdited({ children, onFocus, onMouseOver }: EditedProps) { return ( <span data-slot="comment-edited" onFocus={onFocus} onMouseOver={onMouseOver} className="text-ds-text-subtlest"> {children} </span> );}/* ------------------------------------------------------------------------------------------------- * CommentAction — subtle-link 모양의 텍스트 버튼 (답글, 수정, 삭제, 좋아요 …) * -----------------------------------------------------------------------------------------------*/export interface CommentActionItemProps extends Omit<React.ComponentProps<'button'>, 'disabled'> { isDisabled?: boolean;}function CommentAction({ children, isDisabled, className, type = 'button', ...props }: CommentActionItemProps) { return ( <button type={type} disabled={isDisabled} data-slot="comment-action" className={cn( 'inline-flex h-auto cursor-pointer items-center rounded-ds-small border-0 bg-transparent p-ds-0 align-middle ds-font-body font-ds-medium whitespace-nowrap text-ds-text-subtle no-underline', 'hover:underline active:text-ds-text active:underline', 'outline-2 outline-offset-2 outline-transparent focus-visible:outline-ds-border-focused', 'disabled:cursor-not-allowed disabled:text-ds-text-disabled disabled:no-underline', className, )} {...props} > {children} </button> );}/* ------------------------------------------------------------------------------------------------- * Comment * -----------------------------------------------------------------------------------------------*/export interface CommentProps { /** 댓글 아래 액션 목록 (CommentAction 배열) */ actions?: Array<React.ReactNode>; /** 작성자 (CommentAuthor) */ author?: React.ReactNode; /** 왼쪽 아바타 */ avatar: React.ReactNode; /** 중첩 댓글 */ children?: React.ReactNode; /** 본문 */ content?: React.ReactNode; /** 배경을 깔아 강조해요. */ highlighted?: boolean; /** 볼 수 있는 대상을 제한했을 때 그 이름 (자물쇠 아이콘과 함께 표시) */ restrictedTo?: React.ReactNode; /** 저장 중. 시간·액션을 숨기고 savingText 를 보여줘요. */ isSaving?: boolean; /** 저장 중 문구. 기본 "Sending..." */ savingText?: string; /** 작성 시각 (CommentTime) */ time?: React.ReactNode; /** 작성자 유형 등을 나타내는 로젠지 (예: "작성자") */ type?: string; /** 수정됨 표시 (CommentEdited) */ edited?: React.ReactNode; /** 저장 실패. 본문을 흐리게 하고 errorActions 를 보여줘요. */ isError?: boolean; /** 오류 상태에서 보여줄 액션 (다시 시도, 취소 …) */ errorActions?: Array<React.ReactNode>; /** 오류 경고 아이콘의 접근성 이름 */ errorIconLabel?: string; id?: string; /** 본문·액션 아래 추가 영역 (답글 입력창 등) */ afterContent?: React.ReactNode; /** 중첩 댓글을 들여쓰기 없이 아바타 아래부터 그려요. */ shouldRenderNestedCommentsInline?: boolean; /** 헤더 heading 레벨. 기본 '3' */ headingLevel?: '1' | '2' | '3' | '4' | '5' | '6'; /** false 면 헤더 항목을 한 줄로 유지해요. */ shouldHeaderWrap?: boolean; testId?: string; className?: string;}function CommentHeader({ author, edited, isError, isSaving, restrictedTo, savingText, time, type, headingLevel = '3', shouldHeaderWrap, testId,}: Pick< CommentProps, 'author' | 'edited' | 'isError' | 'isSaving' | 'restrictedTo' | 'savingText' | 'time' | 'type' | 'headingLevel' | 'shouldHeaderWrap' | 'testId'>) { const Heading = `h${headingLevel}` as 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'; const shouldRender = author || time || restrictedTo || (isSaving && savingText) || edited || type; if (!shouldRender) return null; const noWrap = shouldHeaderWrap === false; const item = 'shrink-0 whitespace-nowrap'; const restricted = restrictedTo && ( <span className="text-ds-text-subtlest"> <span className="inline-flex items-center gap-ds-050"> {noWrap && '•'} <span className="flex px-ds-025"> <Lock aria-hidden className="size-3" /> </span> {restrictedTo} </span> </span> ); return ( <Heading data-slot="comment-header" className="ds-font-body"> <span data-testid={testId} className={cn('flex items-center gap-x-ds-100', noWrap ? 'flex-nowrap' : 'flex-wrap gap-y-ds-0')} > {author && <span className="max-w-full min-w-0 break-all">{author}</span>} {type && ( <span className={item}> <TypeLozenge testId={testId && `${testId}-type`}>{type}</TypeLozenge> </span> )} {time && !isSaving && !isError && <span className={item}>{time}</span>} {edited && <span className={item}>{edited}</span>} {isSaving && savingText && <span className={cn(item, 'text-ds-text-subtlest')}>{savingText}</span>} {restricted} </span> </Heading> );}function CommentFooter({ actions = [], errorActions = [], errorIconLabel, isError, isSaving, testId,}: Pick<CommentProps, 'actions' | 'errorActions' | 'errorIconLabel' | 'isError' | 'isSaving' | 'testId'>) { if (isSaving || !(actions.length || errorActions.length)) return null; const items = isError ? errorActions : actions; return ( <div data-slot="comment-footer" data-testid={testId} className="flex flex-wrap items-center gap-ds-100"> {isError && ( <span className="flex p-ds-050"> <TriangleAlert role={errorIconLabel ? 'img' : undefined} aria-label={errorIconLabel || undefined} aria-hidden={!errorIconLabel || undefined} className="size-4 text-ds-icon-warning" /> </span> )} {items.map((action, index) => ( <React.Fragment key={index}> {index > 0 && ( <span aria-hidden className="text-ds-text-subtle"> · </span> )} {action} </React.Fragment> ))} </div> );}/** 댓글. 헤더(작성자·유형·시간·수정됨·제한) → 본문 → 액션 순으로 그리고, children 으로 답글을 중첩해요. */function Comment({ actions = [], restrictedTo = '', highlighted = false, isSaving = false, savingText = 'Sending...', isError = false, errorActions = [], errorIconLabel = '', author, avatar, children, content, edited, time, type, testId, id, afterContent, shouldRenderNestedCommentsInline, headingLevel = '3', shouldHeaderWrap, className,}: CommentProps) { return ( <CommentLayout testId={testId} id={id} className={className} shouldRenderNestedCommentsInline={shouldRenderNestedCommentsInline} avatar={avatar} highlighted={highlighted} content={ <div className="flex flex-col gap-ds-075"> <div className="flex flex-col gap-ds-050"> <CommentHeader testId={testId && `${testId}-header`} author={author} edited={edited} isError={isError} isSaving={isSaving} restrictedTo={restrictedTo} savingText={savingText} time={time} type={type} headingLevel={headingLevel} shouldHeaderWrap={shouldHeaderWrap} /> <div data-slot="comment-body" data-testid={testId && `${testId}-content`} className={cn('ds-font-body', isSaving || isError ? 'text-ds-text-disabled' : 'text-ds-text')} > {content} </div> </div> <CommentFooter testId={testId && `${testId}-footer`} actions={actions} errorActions={errorActions} errorIconLabel={errorIconLabel} isError={isError} isSaving={isSaving} /> {afterContent} </div> } > {children} </CommentLayout> );}export { Comment, CommentAction, CommentAuthor, CommentEdited, CommentLayout, CommentTime };