/** * @title Menu * @description 사용자가 이동하거나 실행할 수 있는 선택지를 목록으로 보여주는 메뉴예요. */import * as React from 'react';import { cva } from 'class-variance-authority';import { cn } from '@/lib/utils';// Atlassian @atlaskit/menu — internal/components/menu-item-primitive, menu-section/*, menu-item/* 스펙type MenuSpacing = 'cozy' | 'compact';type SelectionStyle = 'border' | 'notch' | 'none';/** MenuGroup 이 내려주는 밀도 (cozy 40px / compact 32px) */const SpacingContext = React.createContext<MenuSpacing>('cozy');/** 선택된 항목의 왼쪽 표시 방식. MenuGroup 은 border, 체크박스·라디오는 none */const SelectionStyleContext = React.createContext<SelectionStyle>('border');const transitionClass = 'transition-[background-color,border-color,color] ease-[cubic-bezier(0.4,1,0.6,1)]';const menuItemVariants = cva( [ 'relative m-0 box-border flex w-full min-h-10 cursor-pointer select-none items-center border-0 text-left no-underline hover:no-underline', // 포커스 링은 항목 안쪽으로 2px (outline-offset: -2px) 'outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-solid focus-visible:outline-ds-border-focused', ], { variants: { spacing: { cozy: 'py-ds-100 px-ds-200', compact: 'min-h-8 py-ds-050 px-ds-150', }, state: { unselected: cn( 'bg-ds-background-neutral-subtle text-current visited:text-current', 'hover:bg-ds-background-neutral-subtle-hovered active:bg-ds-background-neutral-subtle-hovered', transitionClass, 'duration-50 active:duration-100', ), selected: cn( 'bg-ds-background-selected text-ds-text-selected visited:text-ds-text-selected', 'hover:bg-ds-background-selected-hovered hover:text-ds-text-selected', 'active:bg-ds-background-selected-pressed active:text-ds-text-selected', transitionClass, 'duration-100 hover:duration-50', ), disabled: cn( 'cursor-not-allowed bg-ds-background-neutral-subtle text-ds-text-disabled', 'hover:bg-ds-background-neutral-subtle hover:text-ds-text-disabled active:bg-ds-background-neutral-subtle active:text-ds-text-disabled', ), }, selectionStyle: { none: '', // 선택 표시: 왼쪽 2px 세로 막대 border: 'before:absolute before:inset-y-0 before:left-0 before:w-[2px] before:bg-ds-border-selected before:content-[""]', // 선택 표시: 위아래 12px 를 띄운 4px 노치 notch: 'before:absolute before:inset-y-ds-150 before:left-0 before:w-1 before:rounded-r-ds-small before:bg-ds-border-selected before:content-[""]', }, }, defaultVariants: { spacing: 'cozy', state: 'unselected', selectionStyle: 'none', }, },);interface MenuItemStyleOptions { spacing: MenuSpacing; isSelected?: boolean; isDisabled?: boolean; selectionStyle: SelectionStyle;}/** 메뉴 항목 공통 클래스 (선택·비활성·밀도 반영) */function getMenuItemClassName({ spacing, isSelected, isDisabled, selectionStyle }: MenuItemStyleOptions) { return menuItemVariants({ spacing, state: isDisabled ? 'disabled' : isSelected ? 'selected' : 'unselected', selectionStyle: !isDisabled && isSelected ? selectionStyle : 'none', });}/** 컨텍스트(밀도·선택 표시 방식)를 읽어 항목 클래스를 만든다. */function useMenuItemClassName(isSelected?: boolean, isDisabled?: boolean) { const spacing = React.useContext(SpacingContext); const selectionStyle = React.useContext(SelectionStyleContext); return getMenuItemClassName({ spacing, isSelected, isDisabled, selectionStyle });}export interface MenuItemContentProps { title: React.ReactNode; description?: React.ReactNode; iconBefore?: React.ReactNode; iconAfter?: React.ReactNode; isDisabled?: boolean; shouldTitleWrap?: boolean; shouldDescriptionWrap?: boolean; isTitleHeading?: boolean; testId?: string;}const elemSlotClass = 'flex min-h-6 min-w-6 shrink-0 items-center justify-center [&>svg:not([data-menu-control])]:size-4 [&>svg]:shrink-0';/** 항목 안쪽 배치: 앞 요소 · 제목/설명 · 뒤 요소 */function MenuItemContent({ title, description, iconBefore, iconAfter, isDisabled, shouldTitleWrap = false, shouldDescriptionWrap = false, isTitleHeading = false, testId,}: MenuItemContentProps) { const spacing = React.useContext(SpacingContext); const Title = isTitleHeading ? 'h2' : 'span'; return ( <span data-testid={testId && `${testId}--container`} className={cn( 'flex min-w-0 grow items-center justify-between', spacing === 'compact' ? 'gap-ds-100' : 'gap-ds-150', )} > {iconBefore && ( <span data-item-elem-before="" data-testid={testId && `${testId}--icon-before`} className={elemSlotClass}> {iconBefore} </span> )} {title && ( <span className="flex min-w-0 grow flex-col justify-center overflow-hidden text-left"> <Title data-item-title="" className={cn('m-0 ds-font-body', shouldTitleWrap ? 'break-words' : 'block truncate')} > {title} </Title> {description && ( <span data-item-description="" className={cn( 'ds-font-body-small text-ds-text-subtlest', isDisabled && 'text-ds-text-disabled', shouldDescriptionWrap ? 'break-words' : 'block truncate', )} > {description} </span> )} </span> )} {iconAfter && ( <span data-item-elem-after="" data-testid={testId && `${testId}--icon-after`} className={elemSlotClass}> {iconAfter} </span> )} </span> );}/* ------------------------------------------------------------------------------------------------- * MenuGroup * -----------------------------------------------------------------------------------------------*/export interface MenuGroupProps extends React.ComponentProps<'div'> { /** 항목 밀도. compact 는 높이 32px, cozy 는 40px 이에요. */ spacing?: MenuSpacing; /** 불러오는 중이면 aria-busy 를 켜요. 보통 SkeletonItem 과 함께 써요. */ isLoading?: boolean; minWidth?: number | string; maxWidth?: number | string; minHeight?: number | string; maxHeight?: number | string; /** 메뉴 전체에 붙는 접근성 이름 */ menuLabel?: string; testId?: string;}/** 메뉴의 모든 섹션과 항목을 감싸는 최상위 컨테이너예요. */function MenuGroup({ spacing = 'cozy', isLoading, minWidth, maxWidth, minHeight, maxHeight, menuLabel, testId, className, style, ...props}: MenuGroupProps) { return ( <SpacingContext.Provider value={spacing}> <SelectionStyleContext.Provider value="border"> <div data-slot="menu-group" data-testid={testId} aria-busy={isLoading || undefined} aria-label={menuLabel} className={cn('static flex flex-col overflow-auto', className)} style={{ minWidth, maxWidth, minHeight, maxHeight, ...style }} {...props} /> </SelectionStyleContext.Provider> </SpacingContext.Provider> );}/** @deprecated MenuGroup 에 minWidth·maxWidth 를 직접 지정하세요. (기본 320~800px) */function PopupMenuGroup({ minWidth = 320, maxWidth = 800, ...props }: MenuGroupProps) { return <MenuGroup minWidth={minWidth} maxWidth={maxWidth} {...props} />;}/* ------------------------------------------------------------------------------------------------- * Section * -----------------------------------------------------------------------------------------------*/export interface SectionProps extends Omit<React.ComponentProps<'div'>, 'title'> { /** 섹션 제목. 넣으면 HeadingItem 으로 그려요. */ title?: string; /** 바깥 제목 요소의 id 로 섹션 이름을 지정할 때 */ titleId?: string; /** 위쪽에 1px 구분선을 그려요. */ hasSeparator?: boolean; /** 섹션 안에서만 스크롤되게 해요. */ isScrollable?: boolean; /** 항목을 ul/li 로 감싸 목록으로 알려줘요. */ isList?: boolean; /** 제목이 없을 때 쓰는 접근성 이름 */ label?: string; testId?: string;}/** 연관된 항목을 묶는 영역이에요. 위아래 6px 여백과 선택적 구분선을 가져요. */function Section({ title, titleId, hasSeparator, isScrollable, isList = false, label, testId, className, children, ...props}: SectionProps) { const content = isList ? ( <ul className="m-0 list-none p-0"> {React.Children.toArray(children).map((child, index) => ( <li key={index} className="m-0 list-none p-0"> {child} </li> ))} </ul> ) : ( children ); return ( <div data-slot="menu-section" data-section="" role="group" aria-label={title || label} aria-labelledby={titleId} data-testid={testId} className={cn( // 위아래 6px 여백 'before:block before:h-[6px] before:content-[""] after:block after:h-[6px] after:content-[""]', // 제목 여백: 위 20px(첫 제목 14px) · 아래 6px '[&_[data-menu-heading-item]]:mt-[20px] [&_[data-menu-heading-item]]:mb-[6px] [&_[data-menu-heading-item]:first-of-type]:mt-[14px]', '[&_[data-menu-skeleton-heading-item]]:mt-[25px] [&_[data-menu-skeleton-heading-item]]:mb-[9px] [&_[data-menu-skeleton-heading-item]:first-of-type]:mt-[19px]', 'focus:outline-offset-[-1px]', isScrollable ? 'shrink overflow-auto' : 'shrink-0', hasSeparator ? 'border-t-(length:--ds-border-width) border-solid border-ds-border' : // 구분선 없이 이어지는 섹션은 여백을 겹친다 '[[data-section]+&]:-mt-[6px]', className, )} {...props} > {title !== undefined && ( <HeadingItem testId={testId && `${testId}--heading`} aria-hidden> {title} </HeadingItem> )} {content} </div> );}/* ------------------------------------------------------------------------------------------------- * HeadingItem * -----------------------------------------------------------------------------------------------*/export interface HeadingItemProps extends React.ComponentProps<'div'> { /** 제목 수준 (aria-level). 페이지의 제목 순서에 맞춰요. */ headingLevel?: number; testId?: string;}/** 아래 항목들을 설명하는 섹션 제목이에요. */function HeadingItem({ headingLevel = 2, testId, className, ...props }: HeadingItemProps) { return ( <div role="heading" aria-level={headingLevel} data-slot="menu-heading-item" data-menu-heading-item="" data-testid={testId} className={cn('px-ds-200 py-ds-0 ds-font-heading-xxsmall text-ds-text-subtle', className)} {...props} /> );}/* ------------------------------------------------------------------------------------------------- * ButtonItem / LinkItem / CustomItem * -----------------------------------------------------------------------------------------------*/interface BaseItemProps { /** 항목 제목 */ children?: React.ReactNode; /** 제목 아래 보조 설명 */ description?: React.ReactNode; /** 제목 앞 요소 (아이콘, 아바타 등) */ iconBefore?: React.ReactNode; /** 제목 뒤 요소 (단축키, 배지, 화살표 등) */ iconAfter?: React.ReactNode; isDisabled?: boolean; isSelected?: boolean; /** 제목을 자르지 않고 줄바꿈해요. */ shouldTitleWrap?: boolean; /** 설명을 자르지 않고 줄바꿈해요. */ shouldDescriptionWrap?: boolean; testId?: string;}export interface ButtonItemProps extends BaseItemProps, Omit<React.ComponentProps<'button'>, 'children' | 'disabled' | 'title'> {}/** 다른 곳으로 이동하지 않는 액션(복사, 삭제 등)에 쓰는 버튼 항목이에요. */function ButtonItem({ children, description, iconBefore, iconAfter, isDisabled = false, isSelected = false, shouldTitleWrap, shouldDescriptionWrap, testId, className, ...props}: ButtonItemProps) { const itemClassName = useMenuItemClassName(isSelected, isDisabled); if (!children) return null; return ( <button type="button" data-slot="menu-button-item" data-testid={testId} aria-current={isSelected ? 'true' : undefined} disabled={isDisabled} className={cn(itemClassName, className)} {...props} > <MenuItemContent title={children} description={description} iconBefore={iconBefore} iconAfter={iconAfter} isDisabled={isDisabled} shouldTitleWrap={shouldTitleWrap} shouldDescriptionWrap={shouldDescriptionWrap} testId={testId && `${testId}--primitive`} /> </button> );}export interface LinkItemProps extends BaseItemProps, Omit<React.ComponentProps<'a'>, 'children' | 'title'> { href?: string;}const preventEvent = (e: React.SyntheticEvent) => e.preventDefault();/** 다른 페이지나 리소스로 보내는 링크 항목이에요. */function LinkItem({ children, href, description, iconBefore, iconAfter, isDisabled = false, isSelected = false, shouldTitleWrap, shouldDescriptionWrap, testId, className, onClick, onMouseDown, ...props}: LinkItemProps) { const itemClassName = useMenuItemClassName(isSelected, isDisabled); if (!children) return null; return ( <a data-slot="menu-link-item" data-testid={testId} draggable={false} onDragStart={preventEvent} {...props} href={isDisabled ? undefined : href} aria-current={isSelected ? 'page' : undefined} aria-disabled={isDisabled || undefined} onClick={isDisabled ? preventEvent : onClick} onMouseDown={isDisabled ? preventEvent : onMouseDown} className={cn(itemClassName, className)} > <MenuItemContent title={children} description={description} iconBefore={iconBefore} iconAfter={iconAfter} isDisabled={isDisabled} shouldTitleWrap={shouldTitleWrap} shouldDescriptionWrap={shouldDescriptionWrap} testId={testId && `${testId}--primitive`} /> </a> );}export interface CustomItemComponentProps { className?: string; children?: React.ReactNode; 'data-testid'?: string; 'aria-current'?: 'page' | undefined; 'aria-disabled'?: boolean; tabIndex?: number; [key: string]: unknown;}export interface CustomItemProps extends BaseItemProps { /** 항목 스타일을 받을 컴포넌트 (예: 라우터 링크). className·children 을 받아야 해요. */ component: React.ElementType; [key: string]: unknown;}/** 라우터 링크처럼 직접 만든 요소에 메뉴 항목 스타일을 입혀요. */function CustomItem({ component: Component, children, description, iconBefore, iconAfter, isDisabled = false, isSelected = false, shouldTitleWrap, shouldDescriptionWrap, testId, className, ...props}: CustomItemProps) { const itemClassName = useMenuItemClassName(isSelected, isDisabled); if (!children) return null; return ( <Component data-slot="menu-custom-item" data-testid={testId} aria-current={isSelected ? 'page' : undefined} aria-disabled={isDisabled || undefined} tabIndex={isDisabled ? -1 : undefined} {...props} className={cn(itemClassName, className as string | undefined)} > <MenuItemContent title={children} description={description} iconBefore={iconBefore} iconAfter={iconAfter} isDisabled={isDisabled} shouldTitleWrap={shouldTitleWrap} shouldDescriptionWrap={shouldDescriptionWrap} testId={testId && `${testId}--primitive`} /> </Component> );}/* ------------------------------------------------------------------------------------------------- * SkeletonItem / SkeletonHeadingItem * -----------------------------------------------------------------------------------------------*/const shimmerClass = 'before:animate-pulse after:animate-pulse before:[animation-duration:1.5s] after:[animation-duration:1.5s]';export interface SkeletonItemProps { /** 앞에 원형(아바타) 자리표시자를 그려요. */ hasAvatar?: boolean; /** 앞에 사각형(아이콘) 자리표시자를 그려요. */ hasIcon?: boolean; /** 깜빡이는 애니메이션 */ isShimmering?: boolean; /** 막대 너비 (예: '50%'). 없으면 순서에 따라 70/50/60/90/35/77% 로 달라져요. */ width?: string | number; testId?: string; className?: string;}/** 항목 내용이 준비되기 전에 자리를 채우는 스켈레톤이에요. */function SkeletonItem({ hasAvatar, hasIcon, isShimmering = false, width, testId, className }: SkeletonItemProps) { return ( <div data-slot="menu-skeleton-item" data-testid={testId} className={cn( 'pointer-events-none flex min-h-10 items-center px-ds-250 py-ds-0', 'after:mt-px after:h-[9px] after:rounded-full after:bg-ds-skeleton after:content-[""]', width ? 'after:basis-(--width)' : '[&:nth-of-type(1n)]:after:basis-[70%] [&:nth-of-type(2n)]:after:basis-[50%] [&:nth-of-type(3n)]:after:basis-[60%] [&:nth-of-type(4n)]:after:basis-[90%] [&:nth-of-type(5n)]:after:basis-[35%] [&:nth-of-type(6n)]:after:basis-[77%]', (hasAvatar || hasIcon) && 'before:ml-ds-025 before:mr-[14px] before:size-5 before:shrink-0 before:bg-ds-skeleton before:content-[""]', hasAvatar && 'before:rounded-full', hasIcon && 'before:rounded-ds-small', isShimmering && shimmerClass, className, )} style={width !== undefined ? ({ '--width': typeof width === 'number' ? `${width}px` : width } as React.CSSProperties) : undefined} /> );}export interface SkeletonHeadingItemProps { isShimmering?: boolean; /** 막대 너비 (기본 30%) */ width?: string | number; testId?: string; className?: string;}/** 섹션 제목 자리를 채우는 스켈레톤이에요. */function SkeletonHeadingItem({ isShimmering = false, width, testId, className }: SkeletonHeadingItemProps) { return ( <div data-slot="menu-skeleton-heading-item" data-menu-skeleton-heading-item="" data-testid={testId} className={cn( 'px-ds-200 py-ds-0', 'after:block after:h-2 after:w-[30%] after:rounded-full after:bg-ds-skeleton after:content-[""]', width !== undefined && 'after:w-(--width)', isShimmering && shimmerClass, className, )} style={width !== undefined ? ({ '--width': typeof width === 'number' ? `${width}px` : width } as React.CSSProperties) : undefined} /> );}export { MenuGroup, PopupMenuGroup, Section, HeadingItem, ButtonItem, LinkItem, CustomItem, SkeletonItem, SkeletonHeadingItem, MenuItemContent, SpacingContext, SelectionStyleContext, menuItemVariants, getMenuItemClassName, useMenuItemClassName,};export type { MenuSpacing, SelectionStyle };