/** * @title Breadcrumbs * @description 계층 구조 안에서 현재 페이지의 위치를 보여주고 상위 페이지로 돌아가게 해주는 탐색 경로예요. */import * as React from 'react';import { DropdownMenu as DropdownMenuPrimitive } from 'radix-ui';import { Check, Link as LinkIcon, MoreHorizontal } from 'lucide-react';import { cn } from '@/lib/utils';import { IconButton } from '@/components/ui/button';// Atlassian @atlaskit/breadcrumbs 17.8 — platform_dst_breadcrumbs-refresh ON// (components/breadcrumbs InnerBreadcrumbsNew, internal/breadcrumbs-item-base, internal/step,// internal/ellipsis-popup, internal/use-overflow-collapse, breadcrumbs-current-item 스펙)// 새 탐색 경로는 개수(maxItems)가 아니라 **너비**를 기준으로, 가운데 항목부터 말줄임 메뉴로 접어요.type BreadcrumbsSize = 'medium' | 'small';const BreadcrumbsSizeContext = React.createContext<BreadcrumbsSize>('medium');/** 너비 측정을 위해 각 항목의 li 를 Breadcrumbs 에 등록해요. */const ItemRegistrationContext = React.createContext<((el: HTMLLIElement | null) => void) | undefined>(undefined);// li 공통 (breadcrumbs-item-base): 항목 뒤 "/" 구분자 (스크린 리더는 읽지 않음)const separatorClass = "[&:not(:last-child)]:after:w-2 [&:not(:last-child)]:after:shrink-0 [&:not(:last-child)]:after:px-ds-100 [&:not(:last-child)]:after:py-ds-025 [&:not(:last-child)]:after:text-center [&:not(:last-child)]:after:text-ds-text-subtlest [&:not(:last-child)]:after:content-['/'_/_'']";const itemWrapperClass = cn( 'm-0 box-border flex max-w-full flex-row items-center self-center p-0 font-ds-body', '[&:not(:last-child)]:after:self-baseline', separatorClass,);// 링크·버튼 공통 (Step, refresh): 높이 24px, gap 0, 밑줄은 투명 → hover 때 subtlestconst stepClass = (size: BreadcrumbsSize) => cn( 'box-border inline-flex h-6 min-w-0 shrink cursor-pointer items-center gap-ds-0 rounded-ds-xsmall border-none bg-ds-background-neutral-subtle px-ds-0 py-ds-0', size === 'small' ? 'ds-font-body-small' : 'ds-font-body', 'text-ds-text-subtlest underline decoration-transparent', // motion.listitem: hover 50ms, press 100ms 'transition-[background-color,border-color,color,text-decoration-color] duration-(--ds-motion-listitem-hovered-duration) ease-(--ds-motion-listitem-hovered-curve)', 'hover:text-ds-text-subtlest hover:decoration-ds-text-subtlest', 'active:text-ds-text active:decoration-ds-text active:duration-(--ds-motion-listitem-pressed-duration)', 'outline-2 outline-offset-2 outline-transparent focus-visible:outline-ds-border-focused', );const iconWrapperClass = (isSmall: boolean) => cn( 'inline-flex size-6 shrink-0 items-center justify-center overflow-hidden text-ds-icon-subtlest [&_svg]:size-4', isSmall ? 'me-ds-0' : 'me-ds-025', );/* ------------------------------------------------------------------------------------------------- * 너비 기반 접기 (use-overflow-collapse) * -----------------------------------------------------------------------------------------------*/// 말줄임 트리거(24px compact IconButton) + 뒤 구분자(24px)const ELLIPSIS_WIDTH_ESTIMATE = 48;const useIsomorphicLayoutEffect = typeof window === 'undefined' ? React.useEffect : React.useLayoutEffect;function setsEqual(a: Set<number>, b: Set<number>) { if (a.size !== b.size) return false; for (const v of a) if (!b.has(v)) return false; return true;}/** 가운데 항목부터 바깥쪽으로 접는 순서. 첫 항목과 마지막 항목은 마지막까지 남겨요. */function getMiddleOutCollapseOrder(itemCount: number) { const lastIndex = itemCount - 1; const middleIndex = Math.floor((itemCount - 1) / 2); const order: number[] = []; if (middleIndex > 0 && middleIndex < lastIndex) order.push(middleIndex); for (let distance = 1; distance < itemCount; distance++) { const left = middleIndex - distance; const right = middleIndex + distance; if (left > 0) order.push(left); if (right < lastIndex) order.push(right); } return order;}function useOverflowCollapse(itemCount: number) { const itemRefs = React.useRef<Array<HTMLLIElement | null>>([]); const containerRef = React.useRef<HTMLOListElement | null>(null); const naturalWidths = React.useRef<number[]>([]); const naturalWidthsReady = React.useRef(false); const [naturalWidthsReadyState, setNaturalWidthsReadyState] = React.useState(false); const [collapsedIndices, setCollapsedIndices] = React.useState<Set<number>>(() => new Set()); // 모든 항목을 한 번 그려서 자연 너비를 재요 useIsomorphicLayoutEffect(() => { if (naturalWidthsReady.current) return; const widths = itemRefs.current.slice(0, itemCount).map((el) => el?.offsetWidth ?? 0); if (widths.some((w) => w > 0)) { naturalWidths.current = widths; naturalWidthsReady.current = true; setNaturalWidthsReadyState(true); } }); const recalculate = React.useCallback(() => { const container = containerRef.current; if (!container || !naturalWidthsReady.current) return; const availableWidth = container.offsetWidth; const widths = naturalWidths.current; const empty = (prev: Set<number>) => (prev.size === 0 ? prev : new Set<number>()); if (itemCount <= 2) return setCollapsedIndices(empty); const totalWidth = widths.reduce((sum, w) => sum + w, 0); if (totalWidth <= availableWidth) return setCollapsedIndices(empty); let usedWidth = totalWidth; const next = new Set<number>(); for (const index of getMiddleOutCollapseOrder(itemCount)) { if (next.size === 0) usedWidth += ELLIPSIS_WIDTH_ESTIMATE; next.add(index); usedWidth -= widths[index] ?? 0; if (usedWidth <= availableWidth) break; } // 첫 항목 + 말줄임 + 마지막 항목도 안 들어가면 첫 항목까지 접어요 if (usedWidth > availableWidth) next.add(0); setCollapsedIndices((prev) => (setsEqual(prev, next) ? prev : next)); }, [itemCount]); const registerItem = React.useCallback( (index: number) => (el: HTMLLIElement | null) => { itemRefs.current[index] = el; }, [], ); const observerRef = React.useRef<ResizeObserver | null>(null); const registerContainer = React.useCallback( (el: HTMLOListElement | null) => { observerRef.current?.disconnect(); containerRef.current = el; if (!el || typeof ResizeObserver === 'undefined') return; const observer = new ResizeObserver(() => recalculate()); observer.observe(el); observerRef.current = observer; }, [recalculate], ); React.useEffect(() => { if (naturalWidthsReadyState) recalculate(); }, [naturalWidthsReadyState, recalculate]); React.useEffect(() => () => observerRef.current?.disconnect(), []); // 항목 수가 바뀌면 다시 재요 const isFirstCount = React.useRef(true); React.useEffect(() => { if (isFirstCount.current) { isFirstCount.current = false; return; } naturalWidthsReady.current = false; naturalWidths.current = []; setNaturalWidthsReadyState(false); setCollapsedIndices(new Set()); }, [itemCount]); return { collapsedIndices, naturalWidthsReady: naturalWidthsReadyState, registerItem, registerContainer };}/* ------------------------------------------------------------------------------------------------- * Breadcrumbs * -----------------------------------------------------------------------------------------------*/export interface BreadcrumbsProps extends Omit<React.ComponentProps<'nav'>, 'children'> { children?: React.ReactNode; /** @deprecated 새 탐색 경로는 너비를 기준으로 자동으로 접혀요. 레거시 호환을 위해 받기만 해요. */ maxItems?: number; /** @deprecated 새 탐색 경로에서는 쓰지 않아요. */ itemsBeforeCollapse?: number; /** @deprecated 새 탐색 경로에서는 쓰지 않아요. */ itemsAfterCollapse?: number; /** @deprecated 새 탐색 경로에서는 쓰지 않아요. 접힌 항목은 말줄임 메뉴에서 열어요. */ isExpanded?: boolean; /** @deprecated 새 탐색 경로에서는 쓰지 않아요. */ defaultExpanded?: boolean; /** 말줄임 버튼을 눌렀을 때 호출돼요. */ onExpand?: (event: React.MouseEvent<HTMLButtonElement>) => void; /** nav 의 aria-label */ label?: string; /** 말줄임 버튼의 aria-label */ ellipsisLabel?: string; size?: BreadcrumbsSize; testId?: string;}type ItemLikeProps = { text?: string; href?: string; target?: string; onClick?: (event: React.MouseEvent<HTMLElement>) => void; 'aria-current'?: string;};function Breadcrumbs({ children, // eslint-disable-next-line @typescript-eslint/no-unused-vars maxItems, // eslint-disable-next-line @typescript-eslint/no-unused-vars itemsBeforeCollapse, // eslint-disable-next-line @typescript-eslint/no-unused-vars itemsAfterCollapse, // eslint-disable-next-line @typescript-eslint/no-unused-vars isExpanded, // eslint-disable-next-line @typescript-eslint/no-unused-vars defaultExpanded, onExpand, label = '탐색 경로', ellipsisLabel = '경로 더 보기', size = 'medium', testId, className, ...props}: BreadcrumbsProps) { const childrenArray = React.Children.toArray(children).filter((child): child is React.ReactElement<ItemLikeProps> => React.isValidElement(child), ); const itemCount = childrenArray.length; const { collapsedIndices, naturalWidthsReady, registerItem, registerContainer } = useOverflowCollapse(itemCount); // 마지막 항목은 현재 페이지 (BreadcrumbsCurrentItem 은 스스로 표시) const renderItem = (child: React.ReactElement<ItemLikeProps>, index: number) => { const isLast = index === itemCount - 1; const isCurrentItem = child.type === BreadcrumbsCurrentItem; return ( <ItemRegistrationContext.Provider key={child.key ?? index} value={registerItem(index)}> {isLast && !isCurrentItem ? React.cloneElement(child, { 'aria-current': 'page' }) : child} </ItemRegistrationContext.Provider> ); }; let content: React.ReactNode[]; if (!naturalWidthsReady) { content = childrenArray.map(renderItem); } else { const collapsedItems = Array.from(collapsedIndices) .sort((a, b) => a - b) .map((i) => childrenArray[i].props); content = []; let ellipsisInserted = false; childrenArray.forEach((child, index) => { if (collapsedIndices.has(index)) { if (!ellipsisInserted) { ellipsisInserted = true; content.push( <EllipsisItem key="ellipsis" label={ellipsisLabel} collapsedItems={collapsedItems} onClick={onExpand} testId={testId && `${testId}--breadcrumb-ellipsis`} />, ); } return; } content.push(renderItem(child, index)); }); } return ( <BreadcrumbsSizeContext.Provider value={size}> <nav data-slot="breadcrumbs" aria-label={label} tabIndex={-1} // 포커스 링이 잘리지 않도록 4px 여유를 두고 넘치는 부분은 숨겨요 className={cn('-m-ds-050 min-w-0 overflow-hidden p-ds-050 outline-none', className)} {...props} > <ol ref={registerContainer} data-testid={testId} className="m-0 flex min-w-0 list-none flex-nowrap items-center p-0 text-ds-text-subtlest" > {content} </ol> </nav> </BreadcrumbsSizeContext.Provider> );}/* ------------------------------------------------------------------------------------------------- * BreadcrumbsItem * -----------------------------------------------------------------------------------------------*/export interface BreadcrumbsItemProps extends Omit<React.ComponentProps<'a'>, 'children' | 'onClick' | 'target'> { /** 항목 텍스트 (필수) */ text: string; /** 링크 주소. 없으면 버튼으로 렌더링돼요. */ href?: string; /** 텍스트 앞 아이콘 */ iconBefore?: React.ReactNode; /** 텍스트 앞 요소. iconBefore 와 같아요 (우선). */ elemBefore?: React.ReactNode; /** @deprecated 텍스트 뒤 아이콘 */ iconAfter?: React.ReactNode; onClick?: (event: React.MouseEvent<HTMLElement>) => void; target?: '_blank' | '_parent' | '_self' | '_top' | ''; /** 이 너비(px)를 넘으면 텍스트를 말줄임해요. 앞 아이콘 너비는 따로 더해져요. */ truncationWidth?: number; testId?: string;}function BreadcrumbsItem({ text, href, iconBefore, elemBefore, iconAfter, onClick, target, truncationWidth, testId, title, className, style, ...props}: BreadcrumbsItemProps) { const size = React.useContext(BreadcrumbsSizeContext); const registerRef = React.useContext(ItemRegistrationContext); const isSmall = size === 'small'; const resolvedElemBefore = elemBefore ?? iconBefore; // 앞 아이콘이 컨트롤 안으로 들어와서, 텍스트 예산(truncationWidth)에 아이콘 너비를 더해요 const leadingIconWidth = resolvedElemBefore ? `var(--ds-space-300) + ${isSmall ? '0px' : 'var(--ds-space-025)'}` : '0px'; const maxWidth = truncationWidth != null ? `calc(${truncationWidth}px + ${leadingIconWidth})` : undefined; const content = ( <> {resolvedElemBefore && ( <span data-testid={testId && `${testId}--icon-before`} className={iconWrapperClass(isSmall)}> {resolvedElemBefore} </span> )} <span className={cn('truncate', truncationWidth != null && 'min-w-0 max-w-full shrink', iconAfter && 'me-ds-050')}> {text} </span> {iconAfter && <span className="inline-flex shrink-0 items-center text-ds-icon-subtlest [&_svg]:size-4">{iconAfter}</span>} </> ); const sharedProps = { 'data-slot': 'breadcrumbs-item', 'data-testid': testId, // 말줄임될 수 있으면 전체 텍스트를 title 로 보여준다 title: title ?? (truncationWidth != null ? text : undefined), className: cn(stepClass(size), className), style: { ...style, maxWidth }, }; return ( <li ref={registerRef} className={itemWrapperClass}> {href ? ( <a {...props} {...sharedProps} href={href} target={target || undefined} rel={target === '_blank' ? 'noopener noreferrer' : undefined} onClick={onClick} > {content} </a> ) : ( <button type="button" aria-current={props['aria-current']} aria-label={props['aria-label']} {...sharedProps} onClick={onClick} > {content} </button> )} </li> );}/* ------------------------------------------------------------------------------------------------- * BreadcrumbsCurrentItem — 현재 페이지 (마지막 항목). 진한 글자색 + hover 때 링크 복사 버튼 * -----------------------------------------------------------------------------------------------*/export interface BreadcrumbsCurrentItemProps { /** 현재 페이지 이름 (필수) */ text: string; /** 현재 페이지 주소 (필수). 링크 복사에도 써요. */ href: string; onClick?: (event: React.MouseEvent<HTMLAnchorElement>) => void; target?: '_blank' | '_parent' | '_self' | '_top'; /** 텍스트 앞 요소 */ elemBefore?: React.ReactNode; /** 텍스트 앞 아이콘. elemBefore 와 같아요. */ iconBefore?: React.ReactNode; /** 이 너비(px)를 넘으면 텍스트를 말줄임해요. */ truncationWidth?: number; /** 링크를 복사한 뒤 호출돼요. */ onCopyLink?: () => void; /** 복사 버튼 라벨. 기본 "링크 복사" */ copyLinkLabel?: string; /** 복사한 뒤 라벨. 기본 "링크 복사됨" */ copiedLabel?: string; testId?: string;}const COPY_RESET_DELAY_MS = 2000;function BreadcrumbsCurrentItem({ text, href, onClick, target, elemBefore, iconBefore, truncationWidth, onCopyLink, copyLinkLabel = '링크 복사', copiedLabel = '링크 복사됨', testId,}: BreadcrumbsCurrentItemProps) { const size = React.useContext(BreadcrumbsSizeContext); const registerRef = React.useContext(ItemRegistrationContext); const isSmall = size === 'small'; const resolvedElemBefore = elemBefore ?? iconBefore; const [copied, setCopied] = React.useState(false); const resetTimer = React.useRef<number | undefined>(undefined); React.useEffect(() => () => window.clearTimeout(resetTimer.current), []); const handleCopy = () => { if (typeof navigator === 'undefined' || !navigator.clipboard) return; const url = new URL(href, window.location.href).href; navigator.clipboard.writeText(url).then( () => { setCopied(true); onCopyLink?.(); window.clearTimeout(resetTimer.current); resetTimer.current = window.setTimeout(() => setCopied(false), COPY_RESET_DELAY_MS); }, () => {}, ); }; return ( <li ref={registerRef} data-breadcrumbs-current-item="" className="m-0 box-border flex max-w-full flex-row items-center self-center p-0 font-ds-body"> <span className="group/current box-border inline-flex h-6 items-center gap-ds-050 rounded-ds-small py-ds-025"> {resolvedElemBefore && ( <span data-testid={testId && `${testId}--icon-before`} className={iconWrapperClass(isSmall)}> {resolvedElemBefore} </span> )} <a data-slot="breadcrumbs-current-item" data-testid={testId} href={href} target={target} rel={target === '_blank' ? 'noopener noreferrer' : undefined} aria-current="page" title={truncationWidth != null ? text : undefined} onClick={onClick} style={{ maxWidth: truncationWidth }} className={cn( 'box-border inline-flex items-center gap-ds-050 text-ds-text underline decoration-transparent', isSmall ? 'ds-font-body-small' : 'ds-font-body', truncationWidth != null && 'min-w-0 shrink', // motion.listitem: 선택 100ms, hover 50ms, press 100ms 'transition-[background-color,border-color,color,text-decoration-color] duration-(--ds-motion-listitem-selected-duration) ease-(--ds-motion-listitem-selected-curve)', 'hover:text-ds-text hover:decoration-ds-text hover:duration-(--ds-motion-listitem-hovered-duration)', 'active:text-ds-text active:decoration-ds-text active:duration-(--ds-motion-listitem-pressed-duration)', 'rounded-ds-xsmall outline-2 outline-offset-2 outline-transparent focus-visible:outline-ds-border-focused', )} > <span className={cn('truncate text-ds-text', truncationWidth != null && 'min-w-0 max-w-full shrink')}>{text}</span> </a> {/* 링크 복사: hover·포커스·복사 직후에만 보여요 */} <span data-breadcrumbs-copy-link="" data-copied={copied || undefined} className="inline-flex opacity-0 group-hover/current:opacity-100 focus-within:opacity-100 data-[copied]:opacity-100" > <IconButton appearance="subtle" spacing="compact" icon={copied ? <Check /> : <LinkIcon />} label={copied ? copiedLabel : copyLinkLabel} isTooltipDisabled={false} onClick={handleCopy} testId={testId && `${testId}--copy-link`} /> </span> </span> </li> );}/* ------------------------------------------------------------------------------------------------- * EllipsisItem — 접힌 항목을 여는 말줄임 메뉴 (ellipsis-popup) * -----------------------------------------------------------------------------------------------*/function EllipsisItem({ label, collapsedItems, onClick, testId,}: { label: string; collapsedItems: ItemLikeProps[]; onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void; testId?: string;}) { const [open, setOpen] = React.useState(false); return ( <li className={cn( 'm-0 box-border flex shrink-0 flex-row items-center self-center p-0', '[&:not(:last-child)]:after:self-center', separatorClass, )} > <DropdownMenuPrimitive.Root open={open} onOpenChange={setOpen}> <DropdownMenuPrimitive.Trigger asChild> <IconButton data-slot="breadcrumbs-ellipsis" appearance="subtle" spacing="compact" icon={<MoreHorizontal />} label={label} isSelected={open} onClick={onClick} testId={testId} /> </DropdownMenuPrimitive.Trigger> <DropdownMenuPrimitive.Portal> <DropdownMenuPrimitive.Content side="bottom" align="start" sideOffset={8} collisionPadding={8} className={cn( 'z-[510] box-border block max-w-[480px] overflow-auto rounded-ds-large bg-ds-surface-overlay py-ds-075 text-ds-text shadow-ds-overlay outline-none', 'data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:slide-in-from-top-2 data-[state=open]:duration-150 data-[state=open]:ease-[cubic-bezier(0.4,1,0.6,1)]', 'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-1 data-[state=closed]:duration-100 data-[state=closed]:ease-[cubic-bezier(0.6,0,0.8,0.6)]', 'motion-reduce:animate-none', )} > {collapsedItems.map((item, i) => { const itemClass = cn( 'relative m-0 box-border flex min-h-10 w-full cursor-pointer select-none items-center border-0 px-ds-200 py-ds-100 text-left no-underline', 'ds-font-body text-ds-text visited:text-ds-text hover:no-underline', 'bg-ds-background-neutral-subtle hover:bg-ds-background-neutral-subtle-hovered active:bg-ds-background-neutral-subtle-hovered data-[highlighted]:bg-ds-background-neutral-subtle-hovered', 'transition-[background-color] duration-(--ds-motion-listitem-hovered-duration) ease-(--ds-motion-listitem-hovered-curve)', 'outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ds-border-focused', ); const label = <span className="min-w-0 truncate">{item.text ?? ''}</span>; return ( <DropdownMenuPrimitive.Item key={i} asChild> {item.href ? ( <a href={item.href} target={item.target || undefined} onClick={item.onClick as React.MouseEventHandler<HTMLAnchorElement> | undefined} className={itemClass} > {label} </a> ) : ( <button type="button" onClick={item.onClick as React.MouseEventHandler<HTMLButtonElement> | undefined} className={itemClass} > {label} </button> )} </DropdownMenuPrimitive.Item> ); })} </DropdownMenuPrimitive.Content> </DropdownMenuPrimitive.Portal> </DropdownMenuPrimitive.Root> </li> );}export { Breadcrumbs, BreadcrumbsItem, BreadcrumbsCurrentItem };