/** * @title Navigation System * @description 상단 내비게이션·사이드 내비게이션·메인·Aside·Panel 로 앱 전체 화면 뼈대를 만드는 내비게이션 시스템이에요. */import * as React from 'react';import { Popover as PopoverPrimitive } from 'radix-ui';import { Bell, CircleQuestionMark, Ellipsis, Grip, PanelLeftClose, PanelLeftOpen, Plus, Search as SearchIcon, Settings as SettingsIcon, User,} from 'lucide-react';import { cn } from '@/lib/utils';import { Button, IconButton, type IconProp } from '@/components/ui/button';// Atlassian @atlaskit/navigation-system 11.x 스펙 (platform-dst-tokens-finesse, motion uplift ON)// - Root: grid "banner" / "top-bar" / "side-nav main aside panel"// - TopNav: 높이 48px, 3열 grid(Start / Middle / End), 좌우 12px, 아래 1px border// - SideNav: 기본 320px, clamp(240px, w, 50%), 오른쪽 1px border, 접으면 완전히 사라지고 토글에 500ms 올리면 peek// - PanelSplitter: 17px 잡기 영역, 3px 선(7px 지점), hover 300ms 뒤 border.bold, 드래그 중 border.selected// - Aside 330px, Panel 365px//// 문서 미리보기 안에서도 동작하도록 position:fixed 를 쓰지 않고, Root 를 부모 높이(또는 isFixed 면 화면 높이)에// 맞춘 grid 로 만든다. 반응형 기준은 브라우저 창이 아니라 Root 너비(ResizeObserver, container query)다./* ------------------------------------------------------------------------------------------------- * 상수 * -----------------------------------------------------------------------------------------------*//** 이 너비(px) 이상이면 사이드 내비가 옆에 붙고(데스크톱), 미만이면 Main 위에 겹쳐 떠요(모바일). 원본은 64rem 이지만 컨테이너 기준이라 48rem 으로 낮췄어요. */// Atlaskit 은 창 폭 1024px 기준이지만, 이 구현은 Root 폭 기준이라 문서 미리보기(약 720px) 안에서도 사이드 내비가 보이도록 낮춘다.export const DESKTOP_BREAKPOINT = 640;const SIDE_NAV_DEFAULT_WIDTH = 320;const SIDE_NAV_MIN_WIDTH = 240;const ASIDE_DEFAULT_WIDTH = 330;const PANEL_DEFAULT_WIDTH = 365;const PEEK_START_DELAY_MS = 500;const PEEK_CLOSE_DELAY_MS = 400;const RESIZE_STEP = 20;/** 사이드 내비 enter 250ms / exit 200ms, cubic-bezier(0,.4,0,1) */const slideEnterClass = 'animate-in duration-250 ease-[cubic-bezier(0,0.4,0,1)] motion-reduce:animate-none';/* ------------------------------------------------------------------------------------------------- * Root 컨텍스트 (사이드 내비 접힘 상태) * -----------------------------------------------------------------------------------------------*/type FlyoutState = 'closed' | 'open';type SideNavCallbacks = { onExpand?: (args: { screen: 'desktop' | 'mobile' }) => void; onCollapse?: (args: { screen: 'desktop' | 'mobile' }) => void; onPeekStart?: () => void; onPeekEnd?: (args: { trigger: 'mouse-leave' | 'side-nav-expand' }) => void; canToggleWithShortcut?: () => boolean;};type RootContextValue = { /** Root 너비 (px). 처음 렌더에서는 0 */ rootWidth: number; isDesktop: boolean; isExpandedOnDesktop: boolean; isExpandedOnMobile: boolean; /** 지금 화면 크기에서 사이드 내비가 보이는지 (peek 제외) */ isSideNavExpanded: boolean; flyout: FlyoutState; /** 토글된 적이 있으면 true — 첫 렌더에는 애니메이션을 하지 않아요 */ hasToggled: boolean; isShortcutEnabled: boolean; sideNavId: string; mainId: string; toggle: () => void; expand: () => void; collapse: () => void; requestPeek: () => void; cancelPeekRequest: () => void; scheduleFlyoutClose: () => void; cancelFlyoutClose: () => void; callbacksRef: React.RefObject<SideNavCallbacks>; sideNavElementRef: React.RefObject<HTMLElement | null>;};const RootContext = React.createContext<RootContextValue | null>(null);function useRootContext(component: string) { const ctx = React.useContext(RootContext); if (!ctx) throw new Error(`<${component}> 는 <Root> 안에서 써야 해요.`); return ctx;}/** 사이드 내비를 여닫는(토글) 함수를 돌려줘요. */function useToggleSideNav() { const ctx = useRootContext('useToggleSideNav'); return ctx.toggle;}/** 사이드 내비를 펼치는 함수를 돌려줘요. */function useExpandSideNav() { const ctx = useRootContext('useExpandSideNav'); return ctx.expand;}/** 사이드 내비가 지금 펼쳐져 있는지 알려줘요. */function useSideNavVisibility() { const ctx = useRootContext('useSideNavVisibility'); return { isExpanded: ctx.isSideNavExpanded, isPeeking: ctx.flyout === 'open', isDesktop: ctx.isDesktop };}/* ------------------------------------------------------------------------------------------------- * Root * -----------------------------------------------------------------------------------------------*/export interface RootProps extends React.ComponentProps<'div'> { /** 데스크톱에서 사이드 내비를 접은 채로 시작해요. 모바일은 항상 접힌 채로 시작해요. */ defaultSideNavCollapsed?: boolean; /** `Ctrl` + `[` 단축키로 사이드 내비를 여닫아요. 기본 `false` */ isSideNavShortcutEnabled?: boolean; /** `true` 면 화면 높이(100dvh)를 채워요. 기본은 부모 높이를 채워요(문서 미리보기·임베드용). */ isFixed?: boolean; /** 본문으로 건너뛰기 링크 문구 */ skipLinksLabel?: string; testId?: string;}/** 앱 셸 전체를 감싸는 grid. 바로 아래 자식으로 Banner, TopNav, SideNav, Main, Aside, Panel 을 둬요. */function Root({ defaultSideNavCollapsed = false, isSideNavShortcutEnabled = false, isFixed = false, skipLinksLabel = '본문으로 건너뛰기', testId, className, style, children, ...props}: RootProps) { const rootRef = React.useRef<HTMLDivElement>(null); const [rootWidth, setRootWidth] = React.useState(0); const [isExpandedOnDesktop, setIsExpandedOnDesktop] = React.useState(!defaultSideNavCollapsed); const [isExpandedOnMobile, setIsExpandedOnMobile] = React.useState(false); const [flyout, setFlyout] = React.useState<FlyoutState>('closed'); const [hasToggled, setHasToggled] = React.useState(false); const callbacksRef = React.useRef<SideNavCallbacks>({}); const sideNavElementRef = React.useRef<HTMLElement | null>(null); const peekTimer = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined); const closeTimer = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined); const baseId = React.useId(); const sideNavId = `${baseId}-side-nav`; const mainId = `${baseId}-main`; // 첫 렌더(프리렌더)는 데스크톱으로 가정 const isDesktop = rootWidth === 0 || rootWidth >= DESKTOP_BREAKPOINT; const isSideNavExpanded = isDesktop ? isExpandedOnDesktop : isExpandedOnMobile; React.useEffect(() => { const el = rootRef.current; if (!el) return; setRootWidth(el.getBoundingClientRect().width); const observer = new ResizeObserver(([entry]) => setRootWidth(entry.contentRect.width)); observer.observe(el); return () => observer.disconnect(); }, []); React.useEffect( () => () => { clearTimeout(peekTimer.current); clearTimeout(closeTimer.current); }, [], ); // 최신 값을 이벤트 핸들러에서 읽기 위한 ref const stateRef = React.useRef({ isDesktop, isExpandedOnDesktop, isExpandedOnMobile, flyout }); stateRef.current = { isDesktop, isExpandedOnDesktop, isExpandedOnMobile, flyout }; const endPeek = React.useCallback((trigger: 'mouse-leave' | 'side-nav-expand') => { clearTimeout(closeTimer.current); clearTimeout(peekTimer.current); if (stateRef.current.flyout === 'open') { setFlyout('closed'); callbacksRef.current.onPeekEnd?.({ trigger }); } }, []); const expand = React.useCallback(() => { const s = stateRef.current; setHasToggled(true); if (s.isDesktop) { endPeek('side-nav-expand'); if (!s.isExpandedOnDesktop) { setIsExpandedOnDesktop(true); callbacksRef.current.onExpand?.({ screen: 'desktop' }); } } else if (!s.isExpandedOnMobile) { setIsExpandedOnMobile(true); callbacksRef.current.onExpand?.({ screen: 'mobile' }); } }, [endPeek]); const collapse = React.useCallback(() => { const s = stateRef.current; setHasToggled(true); if (s.isDesktop) { if (s.isExpandedOnDesktop) { setIsExpandedOnDesktop(false); callbacksRef.current.onCollapse?.({ screen: 'desktop' }); } } else if (s.isExpandedOnMobile) { setIsExpandedOnMobile(false); callbacksRef.current.onCollapse?.({ screen: 'mobile' }); } }, []); const toggle = React.useCallback(() => { const s = stateRef.current; const expanded = s.isDesktop ? s.isExpandedOnDesktop : s.isExpandedOnMobile; // peek 중에 토글을 누르면 그대로 펼친다 if (expanded && !(s.isDesktop && s.flyout === 'open')) collapse(); else expand(); }, [collapse, expand]); const requestPeek = React.useCallback(() => { const s = stateRef.current; clearTimeout(closeTimer.current); if (!s.isDesktop || s.isExpandedOnDesktop || s.flyout === 'open') return; clearTimeout(peekTimer.current); peekTimer.current = setTimeout(() => { const now = stateRef.current; if (!now.isDesktop || now.isExpandedOnDesktop) return; setFlyout('open'); callbacksRef.current.onPeekStart?.(); }, PEEK_START_DELAY_MS); }, []); const cancelPeekRequest = React.useCallback(() => clearTimeout(peekTimer.current), []); const scheduleFlyoutClose = React.useCallback(() => { clearTimeout(peekTimer.current); if (stateRef.current.flyout !== 'open') return; clearTimeout(closeTimer.current); closeTimer.current = setTimeout(() => { // 사이드 내비 안에서 연 팝업(플라이아웃 메뉴 등)이 열려 있으면 닫지 않는다 const open = sideNavElementRef.current?.querySelector('[aria-expanded="true"][aria-haspopup]'); if (open) return; endPeek('mouse-leave'); }, PEEK_CLOSE_DELAY_MS); }, [endPeek]); const cancelFlyoutClose = React.useCallback(() => clearTimeout(closeTimer.current), []); // 데스크톱 ↔ 모바일이 바뀌면 peek 를 닫는다 React.useEffect(() => { if (!isDesktop) endPeek('mouse-leave'); }, [isDesktop, endPeek]); // Ctrl + [ 단축키 React.useEffect(() => { if (!isSideNavShortcutEnabled) return; const onKeyDown = (event: KeyboardEvent) => { if (!event.ctrlKey || event.key !== '[' || event.repeat) return; if (callbacksRef.current.canToggleWithShortcut && !callbacksRef.current.canToggleWithShortcut()) return; event.preventDefault(); toggle(); }; window.addEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown); }, [isSideNavShortcutEnabled, toggle]); // 모바일: 바깥을 누르거나 Escape 를 누르면 사이드 내비를 닫는다 React.useEffect(() => { if (isDesktop || !isExpandedOnMobile) return; const onPointerDown = (event: PointerEvent) => { const target = event.target as Element | null; if (!target) return; if (sideNavElementRef.current?.contains(target)) return; if (target.closest('[data-side-nav-toggle]')) return; // 포털로 뜬 레이어(팝업, 툴팁) 안을 누른 경우는 무시 if (!rootRef.current?.contains(target)) return; collapse(); }; const onKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') collapse(); }; document.addEventListener('pointerdown', onPointerDown); document.addEventListener('keydown', onKeyDown); return () => { document.removeEventListener('pointerdown', onPointerDown); document.removeEventListener('keydown', onKeyDown); }; }, [isDesktop, isExpandedOnMobile, collapse]); const value = React.useMemo<RootContextValue>( () => ({ rootWidth, isDesktop, isExpandedOnDesktop, isExpandedOnMobile, isSideNavExpanded, flyout, hasToggled, isShortcutEnabled: isSideNavShortcutEnabled, sideNavId, mainId, toggle, expand, collapse, requestPeek, cancelPeekRequest, scheduleFlyoutClose, cancelFlyoutClose, callbacksRef, sideNavElementRef, }), [ rootWidth, isDesktop, isExpandedOnDesktop, isExpandedOnMobile, isSideNavExpanded, flyout, hasToggled, isSideNavShortcutEnabled, sideNavId, mainId, toggle, expand, collapse, requestPeek, cancelPeekRequest, scheduleFlyoutClose, cancelFlyoutClose, ], ); const gridStyle: React.CSSProperties = isDesktop ? { gridTemplateAreas: '"banner banner banner banner" "top-bar top-bar top-bar top-bar" "side-nav main aside panel"', gridTemplateColumns: 'auto minmax(0, 1fr) auto auto', gridTemplateRows: 'auto auto minmax(0, 1fr)', } : { gridTemplateAreas: '"banner" "top-bar" "main" "aside"', gridTemplateColumns: 'minmax(0, 1fr)', gridTemplateRows: 'auto auto minmax(0, 1fr) auto', }; return ( <RootContext.Provider value={value}> <div ref={rootRef} data-slot="navigation-system-root" data-layout={isDesktop ? 'desktop' : 'mobile'} data-testid={testId} className={cn( '@container/nav relative isolate box-border grid w-full min-h-0 overflow-hidden bg-ds-surface text-ds-text ds-font-body', isFixed ? 'h-dvh' : 'h-full', className, )} style={{ ...gridStyle, ...style }} {...props} > <a href={`#${mainId}`} className="absolute start-ds-100 top-ds-100 z-[10] -translate-y-[200%] rounded-ds-medium bg-ds-surface-overlay px-ds-150 py-ds-100 ds-font-body text-ds-link shadow-ds-overlay outline-2 outline-offset-2 outline-transparent focus-visible:translate-y-0 focus-visible:outline-ds-border-focused" > {skipLinksLabel} </a> {children} </div> </RootContext.Provider> );}/* ------------------------------------------------------------------------------------------------- * Banner * -----------------------------------------------------------------------------------------------*/export interface BannerProps extends React.ComponentProps<'div'> { /** 높이(px). 기본 48 */ height?: number; testId?: string;}/** 앱 전체에 걸친 알림을 맨 위에 보여주는 자리예요. */function Banner({ height = 48, testId, className, style, ...props }: BannerProps) { return ( <div data-slot="banner" data-testid={testId} className={cn('z-[4] overflow-hidden [grid-area:banner]', className)} style={{ height, ...style }} {...props} /> );}/* ------------------------------------------------------------------------------------------------- * TopNav — top-nav / top-nav-start / top-nav-middle / top-nav-end * -----------------------------------------------------------------------------------------------*/export interface TopNavProps extends React.ComponentProps<'header'> { /** 높이(px). 기본 48 */ height?: number; testId?: string;}/** 앱 맨 위 가로 막대. TopNavStart · TopNavMiddle · TopNavEnd 세 칸으로 나뉘어요. */function TopNav({ height = 48, testId, className, style, ...props }: TopNavProps) { return ( <header data-slot="top-nav" data-testid={testId} className={cn( 'z-[4] box-border grid grid-cols-[auto_1fr_auto] items-center gap-ds-100 border-0 border-b border-solid border-ds-border bg-ds-surface px-ds-150 [grid-area:top-bar]', '@3xl/nav:grid-cols-[1fr_minmax(min-content,max-content)_1fr]', className, )} style={{ height, ...style }} {...props} /> );}export interface TopNavStartProps extends React.ComponentProps<'div'> { /** 맨 앞에 놓이는 사이드 내비 토글 버튼 (`<SideNavToggleButton />`) */ sideNavToggleButton?: React.ReactNode; testId?: string;}/** 왼쪽 칸: 사이드 내비 토글, 앱 전환, 앱 로고 */function TopNavStart({ sideNavToggleButton, testId, className, children, ...props }: TopNavStartProps) { return ( <div data-slot="top-nav-start" data-testid={testId} className={cn( 'col-start-1 box-border flex h-full min-w-0 items-center gap-ds-050 @5xl/nav:w-max @5xl/nav:min-w-[330px]', className, )} {...props} > {sideNavToggleButton} {children} </div> );}/** 가운데 칸: 검색과 만들기 버튼 */function TopNavMiddle({ className, ...props }: React.ComponentProps<'div'>) { return ( <div data-slot="top-nav-middle" className={cn( 'col-start-2 flex min-w-0 items-center justify-end gap-ds-100', '@3xl/nav:grid @3xl/nav:grid-flow-col @3xl/nav:grid-cols-[minmax(min-content,780px)] @3xl/nav:justify-items-end', className, )} {...props} /> );}export interface TopNavEndProps extends Omit<React.ComponentProps<'nav'>, 'children'> { children?: React.ReactNode; /** nav 랜드마크 이름. 기본 "작업" */ label?: string; /** 좁은 화면에서 항목을 접어 넣는 더 보기 버튼 라벨 */ showMoreButtonLabel?: string; testId?: string;}/** 오른쪽 칸: 알림·도움말·설정·프로필. 좁은 화면(48rem 미만)에서는 더 보기 팝업으로 접혀요. */function TopNavEnd({ label = '작업', showMoreButtonLabel = '더 보기', testId, className, children, ...props}: TopNavEndProps) { const { isDesktop } = useRootContext('TopNavEnd'); const [isOpen, setIsOpen] = React.useState(false); React.useEffect(() => { if (isDesktop) setIsOpen(false); }, [isDesktop]); return ( <nav aria-label={label} data-slot="top-nav-end" data-testid={testId} className={cn( 'col-start-3 box-border flex min-w-8 justify-end @3xl/nav:min-w-max @5xl/nav:w-max @5xl/nav:min-w-[330px] @5xl/nav:justify-self-end', className, )} {...props} > {isDesktop ? ( <div role="list" className="flex items-center gap-ds-050"> {children} </div> ) : ( <PopoverPrimitive.Root open={isOpen} onOpenChange={setIsOpen}> <PopoverPrimitive.Trigger asChild> <IconButton appearance="subtle" icon={Ellipsis} label={showMoreButtonLabel} isSelected={isOpen} isTooltipDisabled={false} /> </PopoverPrimitive.Trigger> <PopoverPrimitive.Portal> <PopoverPrimitive.Content align="end" sideOffset={8} className="z-[510] rounded-ds-large bg-ds-surface-overlay p-ds-100 shadow-ds-overlay outline-none animate-in fade-in-0 duration-150" > <div role="list" className="flex items-center gap-ds-050"> {children} </div> </PopoverPrimitive.Content> </PopoverPrimitive.Portal> </PopoverPrimitive.Root> )} </nav> );}/* ------------------------------------------------------------------------------------------------- * Top nav items — AppSwitcher, AppLogo, Search, CreateButton, EndItem, Notifications, Help, Settings, Profile * -----------------------------------------------------------------------------------------------*/type TriggerAriaProps = Pick<React.AriaAttributes, 'aria-controls' | 'aria-expanded' | 'aria-haspopup' | 'aria-describedby'>;export interface AppSwitcherProps extends TriggerAriaProps { /** 스크린 리더와 툴팁에 쓰는 이름 (필수) */ label: React.ReactNode; /** 기본 아이콘(점 9개) 대신 쓸 아이콘 */ icon?: IconProp; onClick?: React.MouseEventHandler<HTMLButtonElement>; isSelected?: boolean; testId?: string;}/** 다른 앱으로 전환하는 메뉴를 여는 버튼이에요. */function AppSwitcher({ label, icon = Grip, onClick, isSelected, testId, ...aria }: AppSwitcherProps) { return ( <IconButton data-slot="app-switcher" appearance="subtle" icon={icon} label={label} onClick={onClick} isSelected={isSelected} isTooltipDisabled={false} testId={testId} {...aria} /> );}export interface AppLogoProps { /** 앱 이름. 64rem 이상(컨테이너 기준 48rem 이상)에서 보여요. */ name: string; /** 링크 접근성 이름. 보통 "<앱 이름> 홈" */ label: string; href: string; /** 24px 앱 아이콘. 컴포넌트를 주면 브랜드 색 타일 안에 그려요. */ icon: IconProp; onClick?: React.MouseEventHandler<HTMLAnchorElement>; testId?: string;}function renderIconProp(icon: IconProp, className?: string) { if (icon == null || typeof icon === 'boolean') return null; if (React.isValidElement(icon)) return icon; if (typeof icon === 'function' || (typeof icon === 'object' && !Array.isArray(icon) && '$$typeof' in icon)) { const Icon = icon as React.ComponentType<{ className?: string; 'aria-hidden'?: boolean }>; return <Icon aria-hidden className={className} />; } return icon as React.ReactNode;}function isComponentIcon(icon: IconProp) { return ( !React.isValidElement(icon) && (typeof icon === 'function' || (typeof icon === 'object' && icon !== null && !Array.isArray(icon) && '$$typeof' in icon)) );}/** 앱 아이콘과 이름으로 된 홈 링크예요. */function AppLogo({ name, label, href, icon, onClick, testId }: AppLogoProps) { return ( <a data-slot="app-logo" data-testid={testId} href={href} aria-label={label} onClick={onClick} className={cn( 'ms-ds-050 flex h-8 shrink-0 items-center rounded-ds-large text-inherit no-underline', 'hover:bg-ds-background-neutral-subtle-hovered active:bg-ds-background-neutral-subtle-pressed', 'outline-2 outline-offset-2 outline-transparent focus-visible:outline-ds-border-focused', )} > <span className="flex items-center gap-ds-075 px-ds-050"> <span className="flex max-h-6 max-w-6 overflow-hidden"> {isComponentIcon(icon) ? ( <span className="inline-flex size-6 items-center justify-center rounded-ds-medium bg-ds-background-brand-bold text-ds-icon-inverse"> {renderIconProp(icon, 'size-4')} </span> ) : ( renderIconProp(icon) )} </span> <span className="hidden w-max max-w-[320px] flex-col justify-center pe-ds-025 select-none @3xl/nav:flex"> <span className="truncate ds-font-body font-ds-semibold text-ds-text">{name}</span> </span> </span> </a> );}export interface SearchProps extends TriggerAriaProps { /** 보이는 문구이자 접근성 이름 (예: "검색") */ label: React.ReactNode; /** 앞 아이콘. 기본 돋보기 */ iconBefore?: IconProp; /** 뒤에 붙는 요소 (예: 단축키 안내) */ elemAfter?: React.ReactNode; onClick?: React.MouseEventHandler<HTMLButtonElement>; testId?: string;}/** 검색 창처럼 보이는 버튼이에요. 누르면 검색 경험(팝업·페이지)을 열어요. 30rem 미만에서는 아이콘 버튼으로 줄어요. */function Search({ label, iconBefore = SearchIcon, elemAfter, onClick, testId, ...aria }: SearchProps) { return ( <> <button type="button" data-slot="search" data-testid={testId} onClick={onClick} {...aria} className={cn( 'box-border hidden h-8 w-full max-w-[680px] cursor-text grid-cols-[auto_1fr_auto] items-center rounded-ds-medium border border-solid border-ds-border-input-search bg-ds-background-input px-ds-075 py-0', 'transition-[background-color,border-color] duration-150 ease-[cubic-bezier(0.4,1,0.6,1)]', 'hover:bg-ds-background-input-hovered active:bg-ds-background-input-pressed', 'outline-2 outline-offset-2 outline-transparent focus-visible:outline-ds-border-focused', '@min-[30rem]/nav:grid', )} > <span className="col-start-1 row-start-1 flex size-6 items-center justify-center text-ds-icon-subtle [&_svg]:size-4"> {renderIconProp(iconBefore)} </span> <span className="col-[1/-1] row-start-1 truncate px-ds-300 text-center ds-font-body text-ds-text-subtlest"> {label} </span> {elemAfter ? <span className="relative col-start-3 row-start-1 flex">{elemAfter}</span> : null} </button> <span className="flex @min-[30rem]/nav:hidden"> <IconButton appearance="subtle" icon={SearchIcon} label={label} onClick={onClick} isTooltipDisabled={false} {...aria} /> </span> </> );}export interface CreateButtonProps { children: React.ReactNode; onClick?: React.MouseEventHandler<HTMLButtonElement>; testId?: string;}/** 어디서든 이슈·페이지를 바로 만드는 primary 버튼이에요. 상단에서 유일한 primary 버튼이에요. */function CreateButton({ children, onClick, testId }: CreateButtonProps) { return ( <Button data-slot="create-button" appearance="primary" iconBefore={Plus} onClick={onClick} testId={testId} className="shrink-0"> {children} </Button> );}export interface EndItemProps extends TriggerAriaProps { /** 스크린 리더와 툴팁에 쓰는 이름 (필수) */ label: React.ReactNode; icon: IconProp; onClick?: React.MouseEventHandler<HTMLButtonElement>; onMouseEnter?: React.MouseEventHandler<HTMLButtonElement>; isSelected?: boolean; /** `false` 면 listitem 래퍼를 만들지 않아요 (직접 감쌀 때) */ isListItem?: boolean; testId?: string;}function ListItemWrapper({ isListItem = true, className, children }: { isListItem?: boolean; className?: string; children: React.ReactNode }) { if (!isListItem) return <>{children}</>; return ( <div role="listitem" className={cn('flex', className)}> {children} </div> );}/** TopNavEnd 에 넣는 아이콘 버튼. 알림·도움말·설정처럼 정해진 항목이 아닌 동작에 써요. */function EndItem({ label, icon, onClick, onMouseEnter, isSelected, isListItem = true, testId, ...aria }: EndItemProps) { return ( <ListItemWrapper isListItem={isListItem}> <IconButton data-slot="end-item" appearance="subtle" icon={icon} label={label} onClick={onClick} onMouseEnter={onMouseEnter} isSelected={isSelected} isTooltipDisabled={false} testId={testId} {...aria} /> </ListItemWrapper> );}export interface NotificationsProps extends Omit<EndItemProps, 'icon'> { /** 읽지 않은 알림 수. 숫자를 주면 빨간 배지로 보여주고 99 를 넘으면 "99+" 로 줄여요. 노드도 받아요. */ badge?: number | React.ReactNode;}/** 알림 버튼. 오른쪽 위에 읽지 않은 개수 배지를 붙여요. */function Notifications({ badge, label, isListItem = true, ...props }: NotificationsProps) { const badgeId = React.useId(); const badgeNode = typeof badge === 'number' ? ( badge > 0 ? ( <span className="box-border inline-flex min-w-ds-300 justify-center rounded-ds-xsmall bg-ds-background-danger-subtler px-ds-050 ds-font-body-small text-ds-text-danger-bolder"> {badge > 99 ? '99+' : badge} </span> ) : null ) : ( badge ); return ( <ListItemWrapper isListItem={isListItem} className="relative"> <EndItem {...props} label={label} icon={Bell} isListItem={false} aria-describedby={badgeNode ? badgeId : undefined} /> {badgeNode ? ( <span id={badgeId} aria-hidden className="pointer-events-none absolute start-ds-150 -top-ds-050 whitespace-nowrap"> {badgeNode} </span> ) : null} </ListItemWrapper> );}/** 도움말 버튼 */function Help(props: Omit<EndItemProps, 'icon'>) { return <EndItem {...props} icon={CircleQuestionMark} />;}/** 설정 버튼 */function Settings(props: Omit<EndItemProps, 'icon'>) { return <EndItem {...props} icon={SettingsIcon} />;}export interface ProfileProps extends Omit<EndItemProps, 'icon'> { /** 프로필 사진 URL */ src?: string; /** 사진이 없을 때 보여줄 이니셜 (한두 글자) */ name?: string;}/** 프로필 버튼. 24px 원형 아바타를 보여줘요. */function Profile({ src, name, ...props }: ProfileProps) { const avatar = ( <span className="inline-flex size-6 items-center justify-center overflow-hidden rounded-ds-full bg-ds-background-accent-blue-subtler ds-font-body-small font-ds-semibold text-ds-text-accent-blue"> {src ? ( <img src={src} alt="" className="size-full object-cover" /> ) : name ? ( name.slice(0, 2) ) : ( <User aria-hidden className="size-4" /> )} </span> ); return <EndItem {...props} icon={avatar} />;}/* ------------------------------------------------------------------------------------------------- * SideNavToggleButton * -----------------------------------------------------------------------------------------------*/export interface SideNavToggleButtonProps { /** 사이드 내비가 접혀 있을 때(누르면 펼침) 라벨 */ expandLabel: React.ReactNode; /** 사이드 내비가 펼쳐져 있을 때(누르면 접음) 라벨 */ collapseLabel: React.ReactNode; onClick?: (event: React.MouseEvent<HTMLElement>, attributes: { isSideNavVisible: boolean }) => void; testId?: string;}/** TopNavStart 맨 앞에 두는 사이드 내비 토글 버튼. 접힌 상태에서 500ms 올려 두면 사이드 내비를 잠깐 띄워(peek) 보여줘요. */function SideNavToggleButton({ expandLabel, collapseLabel, onClick, testId }: SideNavToggleButtonProps) { const ctx = useRootContext('SideNavToggleButton'); const isExpanded = ctx.isSideNavExpanded; const label = isExpanded ? collapseLabel : expandLabel; return ( <span className="flex" data-side-nav-toggle=""> <IconButton data-slot="side-nav-toggle-button" appearance="subtle" icon={isExpanded ? PanelLeftClose : PanelLeftOpen} label={label} aria-expanded={isExpanded} aria-controls={ctx.sideNavId} isTooltipDisabled={false} tooltip={{ content: ctx.isShortcutEnabled ? ( <span className="inline-flex items-center gap-ds-100"> {label} <span className="inline-flex gap-ds-025" aria-hidden> <kbd className="rounded-ds-xsmall bg-ds-background-inverse-subtle px-ds-050 font-[inherit]">Ctrl</kbd> <kbd className="rounded-ds-xsmall bg-ds-background-inverse-subtle px-ds-050 font-[inherit]">[</kbd> </span> </span> ) : undefined, }} testId={testId} onClick={(event) => { onClick?.(event, { isSideNavVisible: !isExpanded || ctx.flyout === 'open' }); ctx.toggle(); }} onMouseEnter={ctx.requestPeek} onMouseLeave={() => { ctx.cancelPeekRequest(); ctx.scheduleFlyoutClose(); }} /> </span> );}/* ------------------------------------------------------------------------------------------------- * PanelSplitter (리사이즈 손잡이) — SideNav · Aside · Panel 안에 둬요 * -----------------------------------------------------------------------------------------------*/type PanelSplitterContextValue = { /** 손잡이 위치: 'end' = 오른쪽 가장자리(SideNav), 'start' = 왼쪽 가장자리(Aside, Panel) */ position: 'start' | 'end'; width: number; minWidth: number; maxWidth: number; defaultWidth: number; setWidth: (width: number) => void; onResizeStart?: () => void; onResizeEnd?: (width: number) => void;};const PanelSplitterContext = React.createContext<PanelSplitterContextValue | null>(null);function clamp(value: number, min: number, max: number) { return Math.min(Math.max(value, min), Math.max(min, max));}export interface PanelSplitterProps { /** 스크린 리더용 이름 (필수). 예: "사이드바 크기 조절" */ label: string; /** 손잡이에 마우스를 올렸을 때 보여줄 툴팁(title) */ tooltipContent?: string; testId?: string;}/** 패널 가장자리에 붙는 너비 조절 손잡이. 드래그하거나 포커스 후 화살표 키(20px)로 조절하고, 더블클릭하면 기본 너비로 돌아가요. */function PanelSplitter({ label, tooltipContent, testId }: PanelSplitterProps) { const ctx = React.useContext(PanelSplitterContext); const [isDragging, setIsDragging] = React.useState(false); const drag = React.useRef<{ startX: number; startWidth: number } | null>(null); if (!ctx) return null; const { position, width, minWidth, maxWidth, defaultWidth, setWidth } = ctx; const direction = position === 'end' ? 1 : -1; return ( <div data-slot="panel-splitter" data-testid={testId} className={cn('absolute inset-y-0 z-[1] outline-none', position === 'end' ? 'end-ds-100' : '-start-[9px]')} > <label title={tooltipContent} className={cn( 'absolute block h-full w-[17px] cursor-ew-resize touch-none bg-transparent p-0 text-transparent', 'transition-[color] delay-300 duration-150 ease-in-out', 'hover:text-ds-border-bold focus-within:text-ds-border-bold', isDragging && 'text-ds-border-selected transition-none', )} onPointerDown={(event) => { if (event.button !== 0) return; event.preventDefault(); event.currentTarget.setPointerCapture(event.pointerId); drag.current = { startX: event.clientX, startWidth: width }; setIsDragging(true); ctx.onResizeStart?.(); }} onPointerMove={(event) => { if (!drag.current) return; const delta = (event.clientX - drag.current.startX) * direction; setWidth(clamp(Math.round(drag.current.startWidth + delta), minWidth, maxWidth)); }} onPointerUp={(event) => { if (!drag.current) return; event.currentTarget.releasePointerCapture(event.pointerId); drag.current = null; setIsDragging(false); ctx.onResizeEnd?.(width); }} onDoubleClick={() => setWidth(clamp(defaultWidth, minWidth, maxWidth))} > <span aria-hidden className="absolute start-[7px] block h-full w-[3px] bg-current" /> <input type="range" aria-label={label} aria-valuetext={`${width}px`} min={minWidth} max={Math.max(minWidth, maxWidth)} step={RESIZE_STEP} value={width} onChange={(event) => setWidth(clamp(Number(event.target.value), minWidth, maxWidth))} className="sr-only" /> </label> </div> );}/** 사이드 내비 오른쪽 가장자리의 너비 조절 손잡이 */function SideNavPanelSplitter({ label = '사이드바 크기 조절', ...props }: Partial<PanelSplitterProps>) { return <PanelSplitter label={label} {...props} />;}/** 패널 너비 상태 + PanelSplitter 컨텍스트 */function usePanelWidth({ defaultWidth, minWidth, maxWidth, rootWidth,}: { defaultWidth: number; minWidth: number; maxWidth?: number; rootWidth: number;}) { const [width, setWidth] = React.useState(defaultWidth); // 기본 최대 너비: Root 의 50% const resolvedMax = maxWidth ?? (rootWidth > 0 ? Math.round(rootWidth / 2) : Number.POSITIVE_INFINITY); const effectiveWidth = clamp(width, minWidth, resolvedMax); return { width: effectiveWidth, setWidth, resolvedMax };}/* ------------------------------------------------------------------------------------------------- * SideNav * -----------------------------------------------------------------------------------------------*/export interface SideNavProps extends Omit<React.ComponentProps<'nav'>, 'children'> { children?: React.ReactNode; /** nav 랜드마크 이름. 기본 "사이드바" */ label?: string; /** 기본 너비(px). 기본 320 */ defaultWidth?: number; /** 최소 너비(px). 기본 240 */ minWidth?: number; /** 최대 너비(px). 기본 Root 너비의 50% */ maxWidth?: number; onExpand?: SideNavCallbacks['onExpand']; onCollapse?: SideNavCallbacks['onCollapse']; onPeekStart?: SideNavCallbacks['onPeekStart']; onPeekEnd?: SideNavCallbacks['onPeekEnd']; /** Ctrl+[ 를 눌렀을 때 실제로 토글할지 추가로 확인해요 */ canToggleWithShortcut?: () => boolean; testId?: string;}/** 제품 안 이동을 담는 왼쪽 세로 내비게이션 자리. 안에 SideNavHeader · SideNavBody · SideNavFooter · SideNavPanelSplitter 를 둬요. */function SideNav({ label = '사이드바', defaultWidth = SIDE_NAV_DEFAULT_WIDTH, minWidth = SIDE_NAV_MIN_WIDTH, maxWidth, onExpand, onCollapse, onPeekStart, onPeekEnd, canToggleWithShortcut, testId, className, style, children, onMouseEnter, onMouseLeave, ...props}: SideNavProps) { const ctx = useRootContext('SideNav'); const { width, setWidth, resolvedMax } = usePanelWidth({ defaultWidth, minWidth, maxWidth, rootWidth: ctx.rootWidth }); // 콜백은 Root 가 상태를 바꿀 때 부른다 ctx.callbacksRef.current = { onExpand, onCollapse, onPeekStart, onPeekEnd, canToggleWithShortcut }; const isFlyout = ctx.isDesktop && !ctx.isExpandedOnDesktop && ctx.flyout === 'open'; const mode: 'docked' | 'flyout' | 'mobile' | 'hidden' = ctx.isDesktop ? ctx.isExpandedOnDesktop ? 'docked' : isFlyout ? 'flyout' : 'hidden' : ctx.isExpandedOnMobile ? 'mobile' : 'hidden'; const splitter = React.useMemo<PanelSplitterContextValue>( () => ({ position: 'end', width, minWidth, maxWidth: resolvedMax, defaultWidth, setWidth }), [width, minWidth, resolvedMax, defaultWidth, setWidth], ); const setRef = React.useCallback( (node: HTMLElement | null) => { ctx.sideNavElementRef.current = node; }, [ctx.sideNavElementRef], ); return ( <nav ref={setRef} id={ctx.sideNavId} aria-label={label} data-slot="side-nav" data-mode={mode} data-testid={testId} className={cn( 'relative box-border h-full min-h-0 flex-col justify-between text-ds-text', mode === 'hidden' ? 'hidden' : 'flex', mode === 'docked' && 'border-0 border-e border-solid border-ds-border bg-ds-surface [grid-area:side-nav]', mode === 'docked' && ctx.hasToggled && cn(slideEnterClass, 'slide-in-from-left'), (mode === 'flyout' || mode === 'mobile') && cn('z-[3] justify-self-start bg-ds-surface-overlay shadow-ds-overlay [grid-area:main]', slideEnterClass, 'slide-in-from-left'), mode === 'mobile' && 'w-[min(90%,320px)]', className, )} style={{ ...(mode === 'docked' || mode === 'flyout' ? { width } : null), ...style }} onMouseEnter={(event) => { ctx.cancelFlyoutClose(); onMouseEnter?.(event); }} onMouseLeave={(event) => { if (isFlyout) ctx.scheduleFlyoutClose(); onMouseLeave?.(event); }} {...props} > <PanelSplitterContext.Provider value={mode === 'docked' ? splitter : null}>{children}</PanelSplitterContext.Provider> </nav> );}/** 사이드 내비 맨 위 (예: 프로젝트 이름) — padding 12px 12px 4px */function SideNavHeader({ className, ...props }: React.ComponentProps<'div'>) { return <div data-slot="side-nav-header" className={cn('px-ds-150 pt-ds-150 pb-ds-050', className)} {...props} />;}/** 스크롤되는 사이드 내비 본문 — 사방 12px */function SideNavBody({ className, children, ...props }: React.ComponentProps<'div'>) { return ( <div data-slot="side-nav-body" className={cn('min-h-0 flex-[1_1_0] overflow-auto', className)} {...props}> <div className="p-ds-150">{children}</div> </div> );}/** `SideNavBody` 의 다른 이름 */const SideNavContent = SideNavBody;/** 사이드 내비 맨 아래 (예: 앱 설정, 피드백) — padding 12px + 위 1px border */function SideNavFooter({ className, ...props }: React.ComponentProps<'div'>) { return ( <div data-slot="side-nav-footer" className={cn('border-0 border-t border-solid border-ds-border p-ds-150', className)} {...props} /> );}/* ------------------------------------------------------------------------------------------------- * Main / MainStickyHeader * -----------------------------------------------------------------------------------------------*/export interface MainProps extends React.ComponentProps<'main'> { /** 최소 너비(px). 기본 320 (데스크톱) */ minWidth?: number; testId?: string;}/** 페이지 본문 자리. 혼자 스크롤돼요. 유일한 필수 영역이에요. */function Main({ minWidth = 320, testId, className, style, ...props }: MainProps) { const ctx = useRootContext('Main'); return ( <main id={ctx.mainId} tabIndex={-1} data-slot="main" data-testid={testId} className={cn('relative box-border min-h-0 min-w-0 overflow-auto bg-ds-surface outline-none [grid-area:main]', className)} style={{ minWidth: ctx.isDesktop ? minWidth : undefined, ...style }} {...props} /> );}/** Main 맨 위에 붙어 있는 헤더 (스크롤해도 위에 고정) */function MainStickyHeader({ className, ...props }: React.ComponentProps<'div'>) { return <div data-slot="main-sticky-header" className={cn('sticky top-0 z-[1] bg-ds-surface', className)} {...props} />;}/* ------------------------------------------------------------------------------------------------- * Aside / Panel * -----------------------------------------------------------------------------------------------*/export interface AsideProps extends React.ComponentProps<'aside'> { /** 기본 너비(px). 기본 330 */ defaultWidth?: number; minWidth?: number; maxWidth?: number; /** aside 랜드마크 이름 */ label?: string; testId?: string;}/** 페이지에 딸린 보조 정보(이슈 세부 정보, 목차) 자리. Main 오른쪽에 놓이고, 좁은 화면에서는 Main 아래로 내려가요. */function Aside({ defaultWidth = ASIDE_DEFAULT_WIDTH, minWidth = 240, maxWidth, label, testId, className, style, children, ...props}: AsideProps) { const ctx = useRootContext('Aside'); const { width, setWidth, resolvedMax } = usePanelWidth({ defaultWidth, minWidth, maxWidth, rootWidth: ctx.rootWidth }); const splitter = React.useMemo<PanelSplitterContextValue>( () => ({ position: 'start', width, minWidth, maxWidth: resolvedMax, defaultWidth, setWidth }), [width, minWidth, resolvedMax, defaultWidth, setWidth], ); return ( <aside aria-label={label} data-slot="aside" data-testid={testId} className={cn('relative box-border min-h-0 bg-ds-surface [grid-area:aside]', ctx.isDesktop ? 'h-full justify-self-end' : 'w-full', className)} style={{ width: ctx.isDesktop ? width : undefined, ...style }} {...props} > <PanelSplitterContext.Provider value={ctx.isDesktop ? splitter : null}> <div className="h-full overflow-auto">{children}</div> </PanelSplitterContext.Provider> </aside> );}export interface PanelProps extends React.ComponentProps<'aside'> { /** 기본 너비(px). 기본 365 */ defaultWidth?: number; minWidth?: number; maxWidth?: number; /** aside 랜드마크 이름 */ label?: string; /** 왼쪽 1px border. 기본 true */ hasBorder?: boolean; testId?: string;}/** 앱 전역 보조 패널(도움말, 알림, AI 도우미) 자리. 오른쪽 끝에서 열리고, 좁은 화면에서는 Main 위에 겹쳐 떠요. */function Panel({ defaultWidth = PANEL_DEFAULT_WIDTH, minWidth = 240, maxWidth, label, hasBorder = true, testId, className, style, children, ...props}: PanelProps) { const ctx = useRootContext('Panel'); const { width, setWidth, resolvedMax } = usePanelWidth({ defaultWidth, minWidth, maxWidth, rootWidth: ctx.rootWidth }); const splitter = React.useMemo<PanelSplitterContextValue>( () => ({ position: 'start', width, minWidth, maxWidth: resolvedMax, defaultWidth, setWidth }), [width, minWidth, resolvedMax, defaultWidth, setWidth], ); return ( <aside aria-label={label} data-slot="panel" data-testid={testId} className={cn( 'relative box-border h-full min-h-0', slideEnterClass, 'slide-in-from-right', ctx.isDesktop ? cn('bg-ds-surface [grid-area:panel]', hasBorder && 'border-0 border-s border-solid border-ds-border') : 'z-[3] w-[min(90%,365px)] justify-self-end bg-ds-surface-overlay shadow-ds-overlay [grid-area:main]', className, )} style={{ width: ctx.isDesktop ? width : undefined, ...style }} {...props} > <PanelSplitterContext.Provider value={ctx.isDesktop ? splitter : null}> <div className="h-full overflow-auto">{children}</div> </PanelSplitterContext.Provider> </aside> );}export { Root, Banner, TopNav, TopNavStart, TopNavMiddle, TopNavEnd, AppSwitcher, AppLogo, Search, CreateButton, EndItem, Notifications, Help, Settings, Profile, SideNavToggleButton, SideNav, SideNavHeader, SideNavBody, SideNavContent, SideNavFooter, SideNavPanelSplitter, PanelSplitter, Main, MainStickyHeader, Aside, Panel, useToggleSideNav, useExpandSideNav, useSideNavVisibility,};