/** * @title Dropdown Menu * @description 버튼을 눌러 액션이나 선택지 목록을 펼쳐 보여주는 드롭다운 메뉴예요. */import * as React from 'react';import { DropdownMenu as DropdownMenuPrimitive } from 'radix-ui';import { ChevronDown } from 'lucide-react';import { cn } from '@/lib/utils';import { Button } from '@/components/ui/button';import { MenuItemContent, Section, SelectionStyleContext, SpacingContext, useMenuItemClassName, type MenuSpacing, type SectionProps,} from '@/components/ui/menu';// Atlassian @atlaskit/dropdown-menu — dropdown-menu, menu-wrapper, item(-checkbox/-radio), group-title 스펙.// 메뉴 본체는 @atlaskit/popup 의 컨테이너(surface-overlay · shadow-overlay · radius-large) 위에 @atlaskit/menu 의 MenuGroup 이 올라간다.type Placement = | 'auto' | 'auto-start' | 'auto-end' | 'top' | 'top-start' | 'top-end' | 'bottom' | 'bottom-start' | 'bottom-end' | 'right' | 'right-start' | 'right-end' | 'left' | 'left-start' | 'left-end';function toRadixPlacement(placement: Placement) { const [main, cross] = placement.split('-') as [string, string | undefined]; const side = (main === 'auto' ? 'bottom' : main) as 'top' | 'bottom' | 'left' | 'right'; const align = (cross ?? 'center') as 'start' | 'center' | 'end'; return { side, align };}/* ------------------------------------------------------------------------------------------------- * Selection store — 메뉴가 닫혔다 열려도 체크박스·라디오 선택을 기억한다 * -----------------------------------------------------------------------------------------------*/interface SelectionStore { checkboxes: Record<string, boolean>; radios: Record<string, string>; setCheckbox: (id: string, value: boolean) => void; setRadio: (groupId: string, id: string) => void;}const SelectionStoreContext = React.createContext<SelectionStore | null>(null);const RadioGroupContext = React.createContext<string | null>(null);function useSelectionStore() { const [checkboxes, setCheckboxes] = React.useState<Record<string, boolean>>({}); const [radios, setRadios] = React.useState<Record<string, string>>({}); return React.useMemo<SelectionStore>( () => ({ checkboxes, radios, setCheckbox: (id, value) => setCheckboxes((prev) => ({ ...prev, [id]: value })), setRadio: (groupId, id) => setRadios((prev) => ({ ...prev, [groupId]: id })), }), [checkboxes, radios], );}/* ------------------------------------------------------------------------------------------------- * DropdownMenu * -----------------------------------------------------------------------------------------------*/export interface CustomTriggerProps<TriggerElement extends HTMLElement = HTMLButtonElement> { /** 트리거 요소에 연결할 ref */ triggerRef: React.Ref<TriggerElement>; /** 메뉴가 열려 있으면 true */ isSelected: boolean; 'aria-expanded': boolean; 'aria-haspopup': 'menu'; 'aria-controls'?: string; testId?: string;}export interface OnOpenChangeArgs { isOpen: boolean; event: Event | React.SyntheticEvent | null;}export interface DropdownMenuProps { /** 문자열이면 기본 버튼(아래 화살표 포함)을, 함수면 직접 만든 트리거를 그려요. */ trigger?: string | ((props: CustomTriggerProps) => React.ReactElement); /** DropdownItemGroup, DropdownItem 등 메뉴 내용 */ children?: React.ReactNode; /** 메뉴 위치 */ placement?: Placement; /** 공간이 부족하면 반대쪽으로 뒤집어요. */ shouldFlip?: boolean; /** 제어 모드 열림 상태 */ isOpen?: boolean; defaultOpen?: boolean; onOpenChange?: (args: OnOpenChangeArgs) => void; /** 항목 대신 스피너를 보여줘요. */ isLoading?: boolean; /** 로딩 스피너의 스크린 리더 문구 */ statusLabel?: string; /** 항목 밀도 */ spacing?: MenuSpacing; /** 메뉴를 트리거 너비에 맞춰요. */ shouldFitContainer?: boolean; /** 포털 대신 트리거 바로 뒤에 렌더링해요. 모달 안에서 써요. */ shouldRenderToParent?: boolean; /** 열릴 때 첫 항목에 포커스를 줘요. (키보드로 열면 항상) */ autoFocus?: boolean; /** 닫힌 뒤 포커스를 돌려줄 요소 */ returnFocusRef?: React.RefObject<HTMLElement | null>; /** 트리거 버튼의 aria-label */ label?: string; /** 메뉴(role=menu)의 aria-label */ menuLabel?: string; zIndex?: number; testId?: string;}/** 버튼을 누르면 액션·선택지 목록을 펼쳐요. */function DropdownMenu({ trigger, children, placement = 'bottom-start', shouldFlip = true, isOpen, defaultOpen = false, onOpenChange, isLoading = false, statusLabel = '불러오는 중', spacing = 'cozy', shouldFitContainer = false, shouldRenderToParent = false, autoFocus = false, returnFocusRef, label, menuLabel, zIndex = 510, testId,}: DropdownMenuProps) { const [localOpen, setLocalOpen] = React.useState(defaultOpen); const open = isOpen ?? localOpen; const store = useSelectionStore(); const triggerRef = React.useRef<HTMLButtonElement>(null); const contentId = React.useId(); const { side, align } = toRadixPlacement(placement); const contentRef = React.useRef<HTMLDivElement>(null); // 키보드로 열면 Radix 가 첫 항목에 포커스한다. autoFocus 면 마우스로 열어도 첫 항목에 포커스. React.useEffect(() => { if (!open || !autoFocus) return; const frame = requestAnimationFrame(() => { contentRef.current?.querySelector<HTMLElement>('[role^="menuitem"]:not([data-disabled])')?.focus(); }); return () => cancelAnimationFrame(frame); }, [open, autoFocus]); const handleOpenChange = (next: boolean) => { if (isOpen === undefined) setLocalOpen(next); onOpenChange?.({ isOpen: next, event: null }); }; const triggerNode = typeof trigger === 'function' ? ( trigger({ triggerRef, isSelected: open, 'aria-expanded': open, 'aria-haspopup': 'menu', 'aria-controls': open ? contentId : undefined, testId: testId && `${testId}--trigger`, }) ) : ( <Button ref={triggerRef} isSelected={open} aria-label={label} iconAfter={<ChevronDown className="size-3" aria-hidden />} data-testid={testId && `${testId}--trigger`} > {trigger} </Button> ); const content = ( <DropdownMenuPrimitive.Content ref={contentRef} id={contentId} data-slot="dropdown-menu-content" data-testid={testId && `${testId}--content`} side={side} align={align} sideOffset={8} collisionPadding={8} avoidCollisions={shouldFlip} aria-label={menuLabel} onCloseAutoFocus={(e) => { if (returnFocusRef?.current) { e.preventDefault(); returnFocusRef.current.focus(); } }} style={{ zIndex }} className={cn( // @atlaskit/popup 컨테이너 'box-border block overflow-auto rounded-ds-large bg-ds-surface-overlay text-ds-text shadow-ds-overlay outline-none', 'max-h-[min(calc(100vh-16px),var(--radix-dropdown-menu-content-available-height))]', shouldFitContainer ? 'w-(--radix-dropdown-menu-trigger-width)' : 'max-w-[800px]', // 열림 150ms 슬라이드 8px + 페이드, 닫힘 100ms 슬라이드 4px + 페이드 'data-[state=open]:animate-in data-[state=open]:fade-in-0 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]:duration-100 data-[state=closed]:ease-[cubic-bezier(0.6,0,0.8,0.6)]', 'data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2', 'data-[side=bottom]:slide-out-to-top-1 data-[side=top]:slide-out-to-bottom-1 data-[side=left]:slide-out-to-right-1 data-[side=right]:slide-out-to-left-1', 'motion-reduce:animate-none', )} > <SpacingContext.Provider value={spacing}> <SelectionStyleContext.Provider value="border"> <div data-slot="dropdown-menu-group" data-testid={testId && `${testId}--menu-group`} aria-busy={isLoading || undefined} className="static flex flex-col" > {isLoading ? ( <div role="menuitem" data-testid={testId && `${testId}--loading-indicator`} className="flex min-w-[160px] justify-center p-ds-250" > <LoadingSpinner label={statusLabel} /> </div> ) : ( children )} </div> </SelectionStyleContext.Provider> </SpacingContext.Provider> </DropdownMenuPrimitive.Content> ); return ( <SelectionStoreContext.Provider value={store}> <DropdownMenuPrimitive.Root open={open} onOpenChange={handleOpenChange} modal={false}> <DropdownMenuPrimitive.Trigger asChild> {triggerNode} </DropdownMenuPrimitive.Trigger> {shouldRenderToParent ? content : <DropdownMenuPrimitive.Portal>{content}</DropdownMenuPrimitive.Portal>} </DropdownMenuPrimitive.Root> </SelectionStoreContext.Provider> );}function LoadingSpinner({ label }: { label: string }) { return ( <svg viewBox="0 0 16 16" className="size-4 animate-spin text-ds-icon-subtle" fill="none" role="img" aria-label={label}> <circle cx="8" cy="8" r="6.5" stroke="currentColor" strokeWidth="1.5" strokeOpacity="0.25" /> <path d="M14.5 8A6.5 6.5 0 0 0 8 1.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" /> </svg> );}/* ------------------------------------------------------------------------------------------------- * DropdownItemGroup * -----------------------------------------------------------------------------------------------*/export type DropdownItemGroupProps = SectionProps;/** 그룹 제목 (heading xxsmall · text-subtle). 스크린 리더에는 그룹 이름으로만 읽혀요. */function GroupTitle({ id, title }: { id: string; title: string }) { return ( <div id={id} aria-hidden="true" data-menu-heading-item="" className="px-ds-200 py-ds-0 ds-font-heading-xxsmall text-ds-text-subtle" > {title} </div> );}/** 관련된 항목을 묶어요. 모든 항목은 그룹 안에 두는 것을 권장해요. */function DropdownItemGroup({ title, children, ...props }: DropdownItemGroupProps) { const uid = React.useId(); const titleId = `dropdown-menu-item-group-title-${uid}`; return ( <Section titleId={title ? titleId : undefined} {...props}> {title && <GroupTitle id={titleId} title={title} />} {children} </Section> );}/* ------------------------------------------------------------------------------------------------- * DropdownItem * -----------------------------------------------------------------------------------------------*/interface ItemCommonProps { children: React.ReactNode; description?: React.ReactNode; isDisabled?: boolean; shouldTitleWrap?: boolean; shouldDescriptionWrap?: boolean; testId?: string; className?: string;}export interface DropdownItemProps extends ItemCommonProps { /** 제목 앞 요소 */ elemBefore?: React.ReactNode; /** 제목 뒤 요소 */ elemAfter?: React.ReactNode; isSelected?: boolean; /** 넣으면 링크 항목(a)으로 그려요. */ href?: string; target?: string; rel?: string; onClick?: (e: React.MouseEvent | React.KeyboardEvent) => void; /** 이 항목을 누른 뒤 포커스를 옮길 요소 */ returnFocusRef?: React.RefObject<HTMLElement | null>; role?: string;}/** 드롭다운 메뉴의 항목. 누르면 메뉴가 닫혀요. */function DropdownItem({ children, description, elemBefore, elemAfter, isDisabled = false, isSelected = false, href, target, rel, onClick, returnFocusRef, role, shouldTitleWrap = true, shouldDescriptionWrap = true, testId, className,}: DropdownItemProps) { const itemClassName = useMenuItemClassName(isSelected, isDisabled); const inner = ( <MenuItemContent title={children} description={description} iconBefore={elemBefore} iconAfter={elemAfter} isDisabled={isDisabled} shouldTitleWrap={shouldTitleWrap} shouldDescriptionWrap={shouldDescriptionWrap} testId={testId && `${testId}--primitive`} /> ); return ( <DropdownMenuPrimitive.Item asChild disabled={isDisabled} onSelect={() => { if (returnFocusRef?.current) returnFocusRef.current.focus(); }} > {href ? ( <a data-slot="dropdown-item" data-testid={testId} role={role ?? 'menuitem'} href={isDisabled ? undefined : href} target={target} rel={rel} draggable={false} aria-current={isSelected ? 'page' : undefined} onClick={(e) => (isDisabled ? e.preventDefault() : onClick?.(e))} className={cn(itemClassName, className)} > {inner} </a> ) : ( <button type="button" data-slot="dropdown-item" data-testid={testId} role={role ?? 'menuitem'} aria-current={isSelected ? 'true' : undefined} onClick={(e) => { if (!isDisabled) onClick?.(e); }} className={cn(itemClassName, className)} > {inner} </button> )} </DropdownMenuPrimitive.Item> );}/* ------------------------------------------------------------------------------------------------- * Checkbox / Radio icons — @atlaskit/dropdown-menu internal/components/*-icon * -----------------------------------------------------------------------------------------------*/function CheckboxIcon({ checked }: { checked: boolean }) { return ( <svg data-menu-control="" width={24} height={24} viewBox="0 0 24 24" role="presentation" className={cn( 'pointer-events-none size-6 overflow-hidden', checked ? 'text-ds-background-selected-bold fill-ds-icon-inverse' : 'text-ds-background-input fill-transparent', )} > <g fillRule="evenodd"> <rect x="5.5" y="5.5" width="13" height="13" rx="1.5" fill="currentColor" className={checked ? 'stroke-ds-border-selected' : 'stroke-ds-border-input'} /> <path fillRule="evenodd" clipRule="evenodd" d="M16.3262 9.48011L15.1738 8.51984L10.75 13.8284L8.82616 11.5198L7.67383 12.4801L10.1738 15.4801C10.3163 15.6511 10.5274 15.75 10.75 15.75C10.9726 15.75 11.1837 15.6511 11.3262 15.4801L16.3262 9.48011Z" fill="inherit" /> </g> </svg> );}function RadioIcon({ checked }: { checked: boolean }) { return ( <svg data-menu-control="" width={24} height={24} viewBox="0 0 24 24" role="presentation" className={cn( 'pointer-events-none size-6 overflow-hidden', checked ? 'text-ds-background-selected-bold fill-ds-icon-inverse' : 'text-ds-background-input fill-transparent', )} > <g fillRule="evenodd"> <circle cx="12" cy="12" r="7.5" fill="currentColor" strokeWidth="1" className={checked ? 'stroke-ds-border-selected' : 'stroke-ds-border-input'} /> <circle cx="12" cy="12" r="3" fill="inherit" /> </g> </svg> );}/* ------------------------------------------------------------------------------------------------- * DropdownItemCheckboxGroup / DropdownItemCheckbox * -----------------------------------------------------------------------------------------------*/export interface DropdownItemCheckboxGroupProps extends SectionProps { /** 그룹 고유 id */ id: string;}/** 여러 개를 고를 수 있는 체크박스 항목 묶음이에요. */function DropdownItemCheckboxGroup(props: DropdownItemCheckboxGroupProps) { return <DropdownItemGroup {...props} />;}export interface DropdownItemCheckboxProps extends ItemCommonProps { /** 선택 상태를 기억하는 고유 id (필수) */ id: string; /** 제어 모드 선택 상태 */ isSelected?: boolean; /** 비제어 모드 초기 선택 상태 */ defaultSelected?: boolean; onClick?: (e: React.MouseEvent | React.KeyboardEvent) => void;}/** 여러 개를 동시에 고를 수 있는 항목. 눌러도 메뉴가 닫히지 않아요. */function DropdownItemCheckbox({ id, children, description, isDisabled = false, isSelected, defaultSelected = false, onClick, shouldTitleWrap = true, shouldDescriptionWrap = true, testId, className,}: DropdownItemCheckboxProps) { const store = React.useContext(SelectionStoreContext); const stored = store?.checkboxes[id]; const selected = isSelected ?? stored ?? defaultSelected; return ( <SelectionStyleContext.Provider value="none"> <CheckableItem role="menuitemcheckbox" id={id} selected={selected} isDisabled={isDisabled} icon={<CheckboxIcon checked={selected} />} description={description} shouldTitleWrap={shouldTitleWrap} shouldDescriptionWrap={shouldDescriptionWrap} testId={testId} className={className} onToggle={(e) => { store?.setCheckbox(id, !selected); onClick?.(e); }} > {children} </CheckableItem> </SelectionStyleContext.Provider> );}/* ------------------------------------------------------------------------------------------------- * DropdownItemRadioGroup / DropdownItemRadio * -----------------------------------------------------------------------------------------------*/export interface DropdownItemRadioGroupProps extends SectionProps { /** 그룹 고유 id (필수). 같은 그룹 안에서는 하나만 선택돼요. */ id: string;}/** 하나만 고를 수 있는 라디오 항목 묶음이에요. */function DropdownItemRadioGroup({ id, ...props }: DropdownItemRadioGroupProps) { return ( <RadioGroupContext.Provider value={id}> <DropdownItemGroup id={id} role="group" {...props} /> </RadioGroupContext.Provider> );}export interface DropdownItemRadioProps extends ItemCommonProps { /** 그룹 안에서 고유한 id (필수) */ id: string; isSelected?: boolean; defaultSelected?: boolean; onClick?: (e: React.MouseEvent | React.KeyboardEvent) => void;}/** 그룹에서 하나만 고르는 항목. 눌러도 메뉴가 닫히지 않아요. */function DropdownItemRadio({ id, children, description, isDisabled = false, isSelected, defaultSelected = false, onClick, shouldTitleWrap = true, shouldDescriptionWrap = true, testId, className,}: DropdownItemRadioProps) { const store = React.useContext(SelectionStoreContext); const groupId = React.useContext(RadioGroupContext) ?? '__default'; const stored = store?.radios[groupId]; const selected = isSelected ?? (stored !== undefined ? stored === id : defaultSelected); return ( <SelectionStyleContext.Provider value="none"> <CheckableItem role="menuitemradio" id={id} selected={selected} isDisabled={isDisabled} icon={<RadioIcon checked={selected} />} description={description} shouldTitleWrap={shouldTitleWrap} shouldDescriptionWrap={shouldDescriptionWrap} testId={testId} className={className} onToggle={(e) => { store?.setRadio(groupId, id); onClick?.(e); }} > {children} </CheckableItem> </SelectionStyleContext.Provider> );}interface CheckableItemProps extends ItemCommonProps { role: 'menuitemcheckbox' | 'menuitemradio'; id: string; selected: boolean; icon: React.ReactNode; onToggle: (e: React.MouseEvent) => void;}function CheckableItem({ role, id, selected, icon, onToggle, children, description, isDisabled, shouldTitleWrap, shouldDescriptionWrap, testId, className,}: CheckableItemProps) { const itemClassName = useMenuItemClassName(selected, isDisabled); return ( <DropdownMenuPrimitive.Item asChild disabled={isDisabled} // 체크박스·라디오는 눌러도 메뉴를 닫지 않는다 onSelect={(e) => e.preventDefault()} > <button type="button" id={id} role={role} aria-checked={selected} data-slot="dropdown-item" data-testid={testId} onClick={(e) => { if (!isDisabled) onToggle(e); }} className={cn(itemClassName, className)} > <MenuItemContent title={children} description={description} iconBefore={icon} isDisabled={isDisabled} shouldTitleWrap={shouldTitleWrap} shouldDescriptionWrap={shouldDescriptionWrap} testId={testId && `${testId}--primitive`} /> </button> </DropdownMenuPrimitive.Item> );}export { DropdownMenu, DropdownItemGroup, DropdownItem, DropdownItemCheckboxGroup, DropdownItemCheckbox, DropdownItemRadioGroup, DropdownItemRadio,};export type { Placement as DropdownMenuPlacement };