/** * @title Side Nav Items * @description 사이드 내비게이션 안에 넣는 메뉴 항목(링크·버튼·펼침·플라이아웃)과 섹션·구분선이에요. */import * as React from 'react';import { Popover as PopoverPrimitive, Tooltip as TooltipPrimitive } from 'radix-ui';import { ChevronDown, ChevronRight, X } from 'lucide-react';import { cn } from '@/lib/utils';import { IconButton } from '@/components/ui/button';// Atlassian @atlaskit/side-nav-items 2.x 스펙 (platform-dst-tokens-finesse ON)// - 항목: 높이 32px(설명 있으면 48px), padding 4px, radius.medium(6px), 라벨 body + medium(500), color.text.subtle// - hover background.neutral.subtle.hovered, pressed background.neutral.subtle.pressed (listitem motion 50/100ms)// - 선택: background.selected + text.selected, 링크는 왼쪽 가운데 2×12px notch(background.selected.bold)// - elemBefore 24×24 + 앞 4px, 텍스트 좌우 4px(elemBefore 없으면 앞 6px)// - ExpandableMenuItem: 단계마다 12px 들여쓰기, chevron(접힘 →, 펼침 ↓)// - FlyoutMenuItem: 오른쪽(right-start) 400px 팝업// - MenuSectionHeading: heading.xxsmall, text.subtlest, padding 8px 0 8px 6px · Divider: <hr> 위 1px border/** elemBefore 에 이 값을 주면 앞 아이콘 자리(24px)를 아예 없애요. */export const COLLAPSE_ELEM_BEFORE = Symbol('collapse-elem-before');const EXPANDABLE_INDENT_PX = 12;/* ------------------------------------------------------------------------------------------------- * Contexts * -----------------------------------------------------------------------------------------------*//** 펼침 메뉴 중첩 단계 (0 = 최상위) */const LevelContext = React.createContext(0);type FlyoutContextValue = { isOpen: boolean; setIsOpen: (open: boolean) => void; contentId: string; triggerRef: React.RefObject<HTMLDivElement | null>;};const FlyoutContext = React.createContext<FlyoutContextValue | null>(null);type ExpandableContextValue = { isExpanded: boolean; setIsExpanded: (expanded: boolean) => void; onExpansionToggle?: (isExpanded: boolean) => void; contentId: string;};const ExpandableContext = React.createContext<ExpandableContextValue | null>(null);const MenuSectionContext = React.createContext<string | null>(null);/* ------------------------------------------------------------------------------------------------- * MenuList / MenuListItem / TopLevelSpacer * -----------------------------------------------------------------------------------------------*//** 메뉴 항목 목록 (role="list") */function MenuList({ className, ...props }: React.ComponentProps<'div'>) { return <div role="list" data-slot="menu-list" className={cn('flex flex-col', className)} {...props} />;}/** 목록 항목 래퍼 (role="listitem"). 직접 만든 항목을 MenuList 에 넣을 때 써요. */function MenuListItem({ className, ...props }: React.ComponentProps<'div'>) { return <div role="listitem" data-slot="menu-list-item" className={className} {...props} />;}/** 최상위 항목 무리 사이에 두는 12px 간격 */function TopLevelSpacer({ className, ...props }: React.ComponentProps<'div'>) { return <div role="none" data-slot="top-level-spacer" className={cn('h-3', className)} {...props} />;}/* ------------------------------------------------------------------------------------------------- * MenuItemBase (내부) * -----------------------------------------------------------------------------------------------*/type ElemBefore = React.ReactNode | typeof COLLAPSE_ELEM_BEFORE;export interface MenuItemCommonProps { children: React.ReactNode; /** 라벨 아래 작은 설명. 있으면 높이가 48px 이 돼요. */ description?: React.ReactNode; /** 앞 24px 자리 (아이콘, 아바타). `COLLAPSE_ELEM_BEFORE` 를 주면 자리를 없애요. */ elemBefore?: ElemBefore; /** 뒤에 붙는 요소 (배지, 개수, 단축키) */ elemAfter?: React.ReactNode; /** 항상 보이는 오른쪽 동작 버튼들 */ actions?: React.ReactNode; /** 마우스를 올리거나 포커스했을 때만 보이는 동작 버튼들 (elemAfter 자리를 대신해요) */ actionsOnHover?: React.ReactNode; /** 라벨이 잘렸을 때 전체 내용을 보여주는 툴팁을 끄려면 true */ isContentTooltipDisabled?: boolean; testId?: string;}interface MenuItemBaseProps extends MenuItemCommonProps { id?: string; href?: string; target?: React.HTMLAttributeAnchorTarget; isSelected?: boolean; isDisabled?: boolean; onClick?: React.MouseEventHandler<HTMLElement>; ariaControls?: string; ariaExpanded?: boolean; ariaHasPopup?: React.AriaAttributes['aria-haspopup']; interactiveRef?: React.Ref<HTMLElement>; visualContentRef?: React.Ref<HTMLDivElement>;}// 안에서 연 팝업(트리거에 aria-expanded=true + aria-haspopup)이 있으면 hover 상태를 유지: has-[[aria-expanded=true][aria-haspopup]]function isClamped(el: HTMLElement | null) { return Boolean(el && (el.scrollWidth > el.clientWidth || el.scrollHeight > el.clientHeight));}const listitemMotion = 'transition-[background-color,border-color,color] duration-(--ds-motion-listitem-hovered-duration) ease-(--ds-motion-listitem-hovered-curve) active:duration-(--ds-motion-listitem-pressed-duration)';function MenuItemBase({ id, children, description, elemBefore, elemAfter, actions, actionsOnHover, href, target, isSelected = false, isDisabled = false, onClick, ariaControls, ariaExpanded, ariaHasPopup, isContentTooltipDisabled, interactiveRef, visualContentRef, testId,}: MenuItemBaseProps) { const level = React.useContext(LevelContext); const labelRef = React.useRef<HTMLSpanElement>(null); const descriptionRef = React.useRef<HTMLSpanElement>(null); const [isTooltipOpen, setIsTooltipOpen] = React.useState(false); const isLink = href !== undefined; const showElemBefore = elemBefore !== COLLAPSE_ELEM_BEFORE; const showHoverActionsWhenNotHovered = Boolean(ariaExpanded && actionsOnHover); const handleClick = (event: React.MouseEvent<HTMLElement>) => { onClick?.(event); setIsTooltipOpen(false); }; const content = ( <div className="col-start-2 row-start-1 flex flex-col content-center"> {/* 중첩 항목도 최상위 항목만큼 누를 수 있게 왼쪽으로 늘린 영역 */} <div aria-hidden className="absolute inset-0" style={{ insetInlineStart: `calc(-1 * ${level} * ${EXPANDABLE_INDENT_PX}px)` }} /> <div className={cn( 'flex min-w-[1ch] flex-col gap-ds-025 overflow-hidden px-ds-050 focus-within:overflow-visible', !showElemBefore && 'ps-ds-075', )} > <span ref={labelRef} className="truncate ds-font-body font-ds-medium"> {children} </span> {description ? ( <span ref={descriptionRef} className="truncate ds-font-body-small"> {description} </span> ) : null} </div> </div> ); const interactiveClass = cn( 'relative col-[1/-1] row-start-1 box-border grid h-full grid-cols-subgrid grid-rows-subgrid items-center rounded-ds-medium border-0 bg-transparent p-ds-050 text-start text-ds-text-subtle no-underline', 'outline-2 outline-offset-2 outline-transparent focus-visible:outline-ds-border-focused', listitemMotion, isDisabled ? 'cursor-not-allowed text-ds-text-disabled' : cn( 'cursor-pointer active:bg-ds-background-neutral-subtle-pressed', isSelected && 'text-ds-text-selected active:bg-ds-background-selected-pressed', ), ); const sharedProps = { id, 'aria-controls': ariaControls, 'aria-haspopup': ariaHasPopup, 'data-testid': testId, onClick: handleClick, }; const interactive = isLink ? ( <a {...sharedProps} ref={interactiveRef as React.Ref<HTMLAnchorElement>} href={href} target={target} aria-current={isSelected ? 'page' : undefined} draggable={false} className={cn(interactiveClass, 'hover:text-inherit visited:text-inherit')} > <span aria-hidden className="absolute start-0 top-1/2 h-3 w-0.5 -translate-y-1/2 bg-(--notch-color)" /> {content} </a> ) : ( <button {...sharedProps} ref={interactiveRef as React.Ref<HTMLButtonElement>} type="button" aria-expanded={ariaExpanded} disabled={isDisabled} className={interactiveClass} > {content} </button> ); const passThrough = '[&:not(:has(button,a))]:pointer-events-none'; return ( <div ref={visualContentRef} data-slot="menu-item" data-selected={isSelected || undefined} data-testid={testId ? `${testId}-container` : undefined} className={cn( 'box-border grid h-8 min-w-[72px] grid-cols-[minmax(0,auto)_1fr_minmax(0,auto)_minmax(0,auto)] grid-rows-[1fr] items-center rounded-ds-medium text-ds-text-subtle select-none', '[--actions-on-hover-opacity:0] [--actions-on-hover-padding:0] [--actions-on-hover-width:0] [--elem-after-display:flex] [--notch-color:transparent]', 'hover:[--actions-on-hover-opacity:1] hover:[--actions-on-hover-padding:var(--ds-space-050)] hover:[--actions-on-hover-width:auto]', 'focus-within:[--actions-on-hover-opacity:1] focus-within:[--actions-on-hover-padding:var(--ds-space-050)] focus-within:[--actions-on-hover-width:auto]', 'has-[[aria-expanded=true][aria-haspopup]]:[--actions-on-hover-opacity:1] has-[[aria-expanded=true][aria-haspopup]]:[--actions-on-hover-padding:var(--ds-space-050)] has-[[aria-expanded=true][aria-haspopup]]:[--actions-on-hover-width:auto]', listitemMotion, !isDisabled && 'hover:bg-ds-background-neutral-subtle-hovered has-[[aria-expanded=true][aria-haspopup]]:bg-ds-background-neutral-subtle-hovered', isSelected && !isDisabled && 'bg-ds-background-selected text-ds-text-selected [--notch-color:var(--ds-background-selected-bold)] hover:bg-ds-background-selected-hovered has-[[aria-expanded=true][aria-haspopup]]:bg-ds-background-selected-hovered duration-(--ds-motion-listitem-selected-duration)', isDisabled && 'text-ds-text-disabled', description && 'h-12', showHoverActionsWhenNotHovered && '[--actions-on-hover-opacity:1] [--actions-on-hover-padding:var(--ds-space-050)] [--actions-on-hover-width:auto]', showHoverActionsWhenNotHovered && elemAfter && '[--elem-after-display:none]', actionsOnHover && elemAfter && 'hover:[--elem-after-display:none] focus-within:[--elem-after-display:none] has-[[aria-expanded=true][aria-haspopup]]:[--elem-after-display:none]', )} > <TooltipPrimitive.Provider delayDuration={300}> <TooltipPrimitive.Root open={isTooltipOpen} onOpenChange={(open) => { if (!open) return setIsTooltipOpen(false); if (isContentTooltipDisabled) return; if (isClamped(labelRef.current) || isClamped(descriptionRef.current)) setIsTooltipOpen(true); }} > <TooltipPrimitive.Trigger asChild>{interactive}</TooltipPrimitive.Trigger> <TooltipPrimitive.Portal> <TooltipPrimitive.Content side="right" align="start" sideOffset={8} className="z-[800] max-w-[240px] rounded-ds-small bg-ds-background-neutral-bold px-ds-075 py-ds-025 ds-font-body-small break-words text-ds-text-inverse animate-in fade-in-0" > <div>{children}</div> {description ? <div>{description}</div> : null} </TooltipPrimitive.Content> </TooltipPrimitive.Portal> </TooltipPrimitive.Root> </TooltipPrimitive.Provider> {showElemBefore ? ( <div className={cn( 'relative col-start-1 row-start-1 box-content flex size-6 shrink-0 items-center justify-center overflow-hidden ps-ds-050 focus-within:overflow-visible', '[&>svg]:size-4', passThrough, )} > {elemBefore as React.ReactNode} </div> ) : null} {actionsOnHover ? ( <div className="relative col-start-3 row-start-1 flex w-(--actions-on-hover-width) items-center gap-ds-050 overflow-hidden pe-(--actions-on-hover-padding) opacity-(--actions-on-hover-opacity) focus-within:overflow-visible"> {actionsOnHover} </div> ) : null} {elemAfter ? ( <div className={cn( 'relative col-start-3 row-start-1 h-6 shrink-0 items-center overflow-hidden pe-ds-050 [display:var(--elem-after-display)] focus-within:overflow-visible', passThrough, )} > {elemAfter} </div> ) : null} {actions ? ( <div className={cn( 'relative col-start-4 row-start-1 flex items-center gap-ds-050 overflow-hidden pe-ds-050 focus-within:overflow-visible', passThrough, )} > {actions} </div> ) : null} </div> );}/* ------------------------------------------------------------------------------------------------- * ButtonMenuItem / LinkMenuItem * -----------------------------------------------------------------------------------------------*/export interface ButtonMenuItemProps extends MenuItemCommonProps { onClick?: React.MouseEventHandler<HTMLButtonElement>; isSelected?: boolean; isDisabled?: boolean; ref?: React.Ref<HTMLButtonElement>;}/** 같은 화면 안의 동작을 실행하는 메뉴 항목 (`<button>`) */function ButtonMenuItem({ onClick, ref, ...props }: ButtonMenuItemProps) { return ( <MenuListItem> <MenuItemBase {...props} interactiveRef={ref} onClick={onClick as React.MouseEventHandler<HTMLElement> | undefined} /> </MenuListItem> );}/** `ButtonMenuItem` 의 다른 이름 */const MenuItem = ButtonMenuItem;export interface LinkMenuItemProps extends MenuItemCommonProps { href: string; target?: React.HTMLAttributeAnchorTarget; /** 현재 페이지면 true — 선택 배경 + 왼쪽 notch + `aria-current="page"` */ isSelected?: boolean; onClick?: React.MouseEventHandler<HTMLAnchorElement>; ref?: React.Ref<HTMLAnchorElement>;}/** 다른 페이지로 이동하는 메뉴 항목 (`<a>`). 선택되면 왼쪽에 notch 가 생겨요. */function LinkMenuItem({ onClick, ref, isSelected = false, ...props }: LinkMenuItemProps) { const itemRef = React.useRef<HTMLDivElement>(null); // 선택된 항목이 사이드 내비 스크롤 영역 밖에 있으면 그 영역만 스크롤해요 (페이지는 움직이지 않아요) React.useEffect(() => { const el = itemRef.current; if (!isSelected || !el) return; const scroller = el.closest<HTMLElement>('[data-slot="side-nav-body"]'); if (!scroller) return; const item = el.getBoundingClientRect(); const box = scroller.getBoundingClientRect(); if (item.top < box.top) scroller.scrollTop -= box.top - item.top; else if (item.bottom > box.bottom) scroller.scrollTop += item.bottom - box.bottom; }, [isSelected]); return ( <MenuListItem ref={itemRef}> <MenuItemBase {...props} isSelected={isSelected} interactiveRef={ref} onClick={onClick as React.MouseEventHandler<HTMLElement> | undefined} /> </MenuListItem> );}/* ------------------------------------------------------------------------------------------------- * ExpandableMenuItem * -----------------------------------------------------------------------------------------------*/export interface ExpandableMenuItemProps { children: React.ReactNode; /** 제어 모드 펼침 상태 */ isExpanded?: boolean; /** 비제어 모드 처음 펼침 상태 */ isDefaultExpanded?: boolean; /** 펼침 상태가 바뀔 때 */ onExpansionToggle?: (isExpanded: boolean) => void; testId?: string;}/** 하위 항목을 제자리에서 펼치고 접는 메뉴 항목. Trigger 와 Content 로 구성해요. */function ExpandableMenuItem({ children, isExpanded: controlled, isDefaultExpanded = false, onExpansionToggle, testId }: ExpandableMenuItemProps) { const [uncontrolled, setUncontrolled] = React.useState(isDefaultExpanded); const isExpanded = controlled ?? uncontrolled; const contentId = React.useId(); const value = React.useMemo<ExpandableContextValue>( () => ({ isExpanded, setIsExpanded: (next) => { if (controlled === undefined) setUncontrolled(next); }, onExpansionToggle, contentId, }), [isExpanded, controlled, onExpansionToggle, contentId], ); return ( <ExpandableContext.Provider value={value}> <MenuListItem data-testid={testId}>{children}</MenuListItem> </ExpandableContext.Provider> );}function useExpandable(component: string) { const ctx = React.useContext(ExpandableContext); if (!ctx) throw new Error(`<${component}> 는 <ExpandableMenuItem> 안에서 써야 해요.`); return ctx;}/** 지금 펼쳐져 있는지 알려줘요 (ExpandableMenuItem 안에서). */function useIsExpanded() { return useExpandable('useIsExpanded').isExpanded;}function ExpandableIcon({ isExpanded, isSelected, provided }: { isExpanded: boolean; isSelected?: boolean; provided?: React.ReactNode }) { const Chevron = isExpanded ? ChevronDown : ChevronRight; return ( <> <span className={cn('items-center justify-center rtl:-scale-x-100 [display:var(--expandable-chevron-display)]', isSelected && 'text-ds-icon-selected')}> <Chevron aria-hidden className="size-3" /> </span> {provided ? ( <span className={cn('[display:var(--expandable-provided-display)] [&_svg]:size-4', isSelected && 'text-ds-icon-selected')}>{provided}</span> ) : null} </> );}export interface ExpandableMenuItemTriggerProps extends Omit<MenuItemCommonProps, 'description'> { /** 주면 라벨이 링크가 되고(선택 가능), chevron 은 따로 누르는 버튼이 돼요. */ href?: string; isSelected?: boolean; onClick?: (event: React.MouseEvent<HTMLElement>, attributes: { isExpanded: boolean }) => void;}/** 펼침 메뉴의 머리 항목. 앞에 chevron 이 보이고, 아이콘을 주면 hover·포커스 때만 chevron 으로 바뀌어요. */function ExpandableMenuItemTrigger({ href, isSelected, elemBefore: provided, onClick, children, ...props}: ExpandableMenuItemTriggerProps) { const ctx = useExpandable('ExpandableMenuItemTrigger'); const id = React.useId(); const providedNode = provided === COLLAPSE_ELEM_BEFORE ? undefined : (provided as React.ReactNode); const toggle = (next: boolean) => { ctx.onExpansionToggle?.(next); ctx.setIsExpanded(next); }; const icon = <ExpandableIcon isExpanded={ctx.isExpanded} isSelected={isSelected} provided={providedNode} />; const elemBefore = href !== undefined ? ( <IconButton appearance="subtle" spacing="compact" icon={<span className="flex items-center justify-center">{icon}</span>} label="" aria-labelledby={id} aria-expanded={ctx.isExpanded} aria-controls={ctx.contentId} onClick={() => toggle(!ctx.isExpanded)} /> ) : ( icon ); return ( <div className={cn( '[--expandable-chevron-display:flex] [--expandable-provided-display:none]', providedNode && cn( '[--expandable-chevron-display:none] [--expandable-provided-display:contents]', 'hover:[--expandable-chevron-display:flex] hover:[--expandable-provided-display:none]', 'has-[:focus-visible]:[--expandable-chevron-display:flex] has-[:focus-visible]:[--expandable-provided-display:none]', 'has-[[aria-expanded=true][aria-haspopup]]:[--expandable-chevron-display:flex] has-[[aria-expanded=true][aria-haspopup]]:[--expandable-provided-display:none]', ), )} > <MenuItemBase {...props} id={id} href={href} isSelected={isSelected} elemBefore={elemBefore} ariaExpanded={ctx.isExpanded} ariaControls={href === undefined ? ctx.contentId : undefined} onClick={(event) => { const next = !ctx.isExpanded; onClick?.(event, { isExpanded: next }); toggle(next); }} > {children} </MenuItemBase> </div> );}/** 펼쳤을 때 보이는 하위 항목 목록. 단계마다 12px 들여써요. 한 번 펼친 뒤에는 접어도 DOM 에 남아요. */function ExpandableMenuItemContent({ className, children, ...props }: React.ComponentProps<'div'>) { const ctx = useExpandable('ExpandableMenuItemContent'); const level = React.useContext(LevelContext); const hasExpanded = React.useRef(false); if (!ctx.isExpanded && !hasExpanded.current) return null; hasExpanded.current = true; return ( <LevelContext.Provider value={level + 1}> <MenuList id={ctx.contentId} className={cn('ps-ds-150', !ctx.isExpanded && 'hidden', className)} {...props}> {children} </MenuList> </LevelContext.Provider> );}/* ------------------------------------------------------------------------------------------------- * FlyoutMenuItem * -----------------------------------------------------------------------------------------------*/export interface FlyoutMenuItemProps { children: React.ReactNode; /** 제어 모드 열림 상태 */ isOpen?: boolean; /** 비제어 모드 처음 열림 상태 */ isDefaultOpen?: boolean; onOpenChange?: (isOpen: boolean) => void;}/** 누르면 오른쪽에 400px 팝업을 띄우는 메뉴 항목. "더 보기", 최근 항목 목록 등에 써요. */function FlyoutMenuItem({ children, isOpen: controlled, isDefaultOpen = false, onOpenChange }: FlyoutMenuItemProps) { const [uncontrolled, setUncontrolled] = React.useState(isDefaultOpen); const isOpen = controlled ?? uncontrolled; const contentId = React.useId(); const triggerRef = React.useRef<HTMLDivElement>(null); const setIsOpen = React.useCallback( (next: boolean) => { if (controlled === undefined) setUncontrolled(next); onOpenChange?.(next); }, [controlled, onOpenChange], ); const value = React.useMemo(() => ({ isOpen, setIsOpen, contentId, triggerRef }), [isOpen, setIsOpen, contentId]); return ( <FlyoutContext.Provider value={value}> <PopoverPrimitive.Root open={isOpen} onOpenChange={setIsOpen}> <MenuListItem>{children}</MenuListItem> </PopoverPrimitive.Root> </FlyoutContext.Provider> );}function useFlyout(component: string) { const ctx = React.useContext(FlyoutContext); if (!ctx) throw new Error(`<${component}> 는 <FlyoutMenuItem> 안에서 써야 해요.`); return ctx;}export interface FlyoutMenuItemTriggerProps extends Omit<MenuItemCommonProps, 'elemAfter' | 'description'> { isSelected?: boolean; onClick?: React.MouseEventHandler<HTMLButtonElement>;}/** 플라이아웃을 여는 항목. 뒤에 작은 오른쪽 chevron 이 붙어요. */function FlyoutMenuItemTrigger({ onClick, ...props }: FlyoutMenuItemTriggerProps) { const ctx = useFlyout('FlyoutMenuItemTrigger'); return ( <PopoverPrimitive.Anchor asChild> <div ref={ctx.triggerRef}> <MenuItemBase {...props} ariaHasPopup="dialog" ariaExpanded={ctx.isOpen} ariaControls={ctx.isOpen ? ctx.contentId : undefined} onClick={(event) => { onClick?.(event as React.MouseEvent<HTMLButtonElement>); ctx.setIsOpen(!ctx.isOpen); }} elemAfter={ <span className="flex items-center px-ds-075 rtl:-scale-x-100"> <ChevronRight aria-hidden className="size-3" /> </span> } /> </div> </PopoverPrimitive.Anchor> );}export interface FlyoutMenuItemContentProps extends Omit<React.ComponentProps<'div'>, 'title'> { /** 대화상자 접근성 이름 (FlyoutHeader 의 title 이 있으면 생략 가능) */ label?: string; /** 최대 높이(px). 기본 760 */ maxHeight?: number; /** 닫힐 때 */ onClose?: () => void; containerTestId?: string;}/** 오른쪽에 뜨는 플라이아웃 팝업 (right-start, 400px). FlyoutHeader · FlyoutBody · FlyoutFooter 를 넣어요. */function FlyoutMenuItemContent({ label, maxHeight = 760, onClose, containerTestId, className, style, children, ...props }: FlyoutMenuItemContentProps) { const ctx = useFlyout('FlyoutMenuItemContent'); const wasOpen = React.useRef(ctx.isOpen); React.useEffect(() => { if (wasOpen.current && !ctx.isOpen) onClose?.(); wasOpen.current = ctx.isOpen; }, [ctx.isOpen, onClose]); return ( <PopoverPrimitive.Portal> <PopoverPrimitive.Content id={ctx.contentId} aria-label={label} side="right" align="start" sideOffset={8} collisionPadding={12} data-testid={containerTestId} onInteractOutside={(event) => { // 트리거를 다시 누르면 트리거의 onClick 이 닫는다 if (ctx.triggerRef.current?.contains(event.target as Node)) event.preventDefault(); }} className={cn( 'z-[510] box-border flex max-h-[min(var(--max-height),calc(100dvh-24px))] w-[min(400px,calc(100vw-24px))] flex-col rounded-ds-large bg-ds-surface-overlay p-ds-100 text-ds-text shadow-ds-overlay outline-none', '[--ds-elevation-surface-current:var(--ds-surface-overlay)]', 'data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:slide-in-from-left-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-left-1 data-[state=closed]:duration-100', className, )} style={{ '--max-height': `${maxHeight}px`, ...style } as React.CSSProperties} {...props} > {children} </PopoverPrimitive.Content> </PopoverPrimitive.Portal> );}export interface FlyoutHeaderProps extends Omit<React.ComponentProps<'div'>, 'title'> { /** 제목 (heading.xsmall) */ title: React.ReactNode; /** 닫기 버튼 접근성 이름. 기본 "닫기" */ closeButtonLabel?: string;}/** 플라이아웃 머리: 제목과 닫기 버튼, 그 아래 검색창 같은 추가 요소 */function FlyoutHeader({ title, closeButtonLabel = '닫기', className, children, ...props }: FlyoutHeaderProps) { const ctx = useFlyout('FlyoutHeader'); return ( <div data-slot="flyout-header" className={cn('flex flex-col gap-ds-075 ps-ds-050', children ? 'pb-ds-050' : 'pb-ds-025', className)} {...props} > <div className="flex flex-row-reverse items-center justify-between gap-ds-200"> <IconButton appearance="subtle" icon={X} label={closeButtonLabel} onClick={() => ctx.setIsOpen(false)} /> <h2 className="m-0 min-w-0 truncate ds-font-heading-xsmall text-ds-text">{title}</h2> </div> {children} </div> );}/** 플라이아웃 본문 (스크롤) */function FlyoutBody({ className, ...props }: React.ComponentProps<'div'>) { return <div data-slot="flyout-body" className={cn('-m-ds-100 min-h-0 flex-1 overflow-auto p-ds-100 pt-ds-050', className)} {...props} />;}/** 플라이아웃 아래 (위 1px border) */function FlyoutFooter({ className, ...props }: React.ComponentProps<'div'>) { return ( <div data-slot="flyout-footer" className={cn('mt-ds-100 border-0 border-t border-solid border-ds-border pt-ds-100', className)} {...props} /> );}/* ------------------------------------------------------------------------------------------------- * MenuSection / MenuSectionHeading / Divider * -----------------------------------------------------------------------------------------------*/export interface MenuSectionProps extends React.ComponentProps<'div'> { /** 제목(MenuSectionHeading)이 없을 때의 접근성 이름 */ ariaLabel?: string; /** MenuList 바로 안에 둘 때 true — listitem 으로 감싸요 */ isMenuListItem?: boolean; testId?: string;}/** 관련 항목을 묶는 그룹 (role="group"). 안에 MenuSectionHeading 과 MenuList 를 둬요. */function MenuSection({ ariaLabel, isMenuListItem = false, testId, children, ...props }: MenuSectionProps) { const id = React.useId(); const content = ( <MenuSectionContext.Provider value={id}> <div role="group" data-slot="menu-section" aria-labelledby={ariaLabel ? undefined : `${id}-heading`} aria-label={ariaLabel} data-testid={isMenuListItem ? undefined : testId} {...props} > {children} </div> </MenuSectionContext.Provider> ); return isMenuListItem ? <MenuListItem data-testid={testId}>{content}</MenuListItem> : content;}export interface MenuSectionHeadingProps extends React.ComponentProps<'p'> { /** 1~6 을 주면 그 수준의 제목 태그로 그려요. 기본은 `<p>` */ headingLevel?: 1 | 2 | 3 | 4 | 5 | 6;}/** 섹션 소제목 — heading.xxsmall, text.subtlest */function MenuSectionHeading({ headingLevel, className, ...props }: MenuSectionHeadingProps) { const id = React.useContext(MenuSectionContext); const Tag = (headingLevel ? `h${headingLevel}` : 'p') as 'p'; return ( <Tag id={id ? `${id}-heading` : undefined} data-slot="menu-section-heading" className={cn('m-0 py-ds-100 ps-ds-075 ds-font-heading-xxsmall text-ds-text-subtlest', className)} {...props} /> );}/** 섹션 사이 구분선 (`<hr>`, 위 1px border) */function Divider({ className, ...props }: React.ComponentProps<'hr'>) { return ( <hr role="none" data-slot="divider" className={cn('my-[0.5em] h-0 w-full border-0 border-t border-solid border-ds-border', className)} {...props} /> );}/* ------------------------------------------------------------------------------------------------- * ContainerAvatar * -----------------------------------------------------------------------------------------------*/export interface ContainerAvatarProps extends Omit<React.ComponentProps<'span'>, 'children'> { /** 이미지 URL */ src?: string; /** 이미지 대체 텍스트. 장식이면 비워 두세요. */ alt?: string; /** 이미지가 없을 때 보여줄 글자나 아이콘 */ children?: React.ReactNode;}/** 프로젝트·스페이스 같은 컨테이너를 나타내는 20×20 사각 아바타 (radius.small). elemBefore 에 넣어요. */function ContainerAvatar({ src, alt = '', className, children, ...props }: ContainerAvatarProps) { return ( <span data-slot="container-avatar" className={cn( 'inline-flex size-5 shrink-0 items-center justify-center overflow-hidden rounded-ds-small bg-ds-background-accent-gray-subtler text-[11px] leading-none font-ds-bold text-ds-text-subtle [&_svg]:size-3', className, )} {...props} > {src ? <img src={src} alt={alt} className="size-full object-cover" /> : children} </span> );}export { ContainerAvatar, MenuList, MenuListItem, TopLevelSpacer, ButtonMenuItem, MenuItem, LinkMenuItem, ExpandableMenuItem, ExpandableMenuItemTrigger, ExpandableMenuItemContent, useIsExpanded, FlyoutMenuItem, FlyoutMenuItemTrigger, FlyoutMenuItemContent, FlyoutHeader, FlyoutBody, FlyoutFooter, MenuSection, MenuSectionHeading, Divider,};