/** * @title Side Navigation * @description 서비스의 최상위 메뉴 간 이동을 돕고 앱의 전체 구조를 탐색할 수 있게 하는 컴포넌트예요. */import * as React from 'react';import { Collapsible as CollapsiblePrimitive, Popover as PopoverPrimitive, Slot, Tooltip as TooltipPrimitive } from 'radix-ui';import { cva, type VariantProps } from 'class-variance-authority';import { ChevronUp, PanelLeft } from 'lucide-react';import { cn } from '@/lib/utils';// SEED qvism recipe: side-navigation.ts (side-navigation, side-navigation-menu-item, side-navigation-inset)// 펼침 240px / 접힘 56px, 전환 200ms////////////////////////////////////////////////////////////////////////////////////// Providerinterface SideNavigationContextValue { collapsed: boolean; setCollapsed: (collapsed: boolean | ((prev: boolean) => boolean)) => void; toggle: () => void;}const SideNavigationContext = React.createContext<SideNavigationContextValue | null>(null);function useSideNavigation() { const context = React.useContext(SideNavigationContext); if (!context) throw new Error('useSideNavigation 은 SideNavigationProvider 안에서 사용해요.'); return context;}export interface SideNavigationProviderProps { /** 접힘 상태 (controlled) */ collapsed?: boolean; /** 처음 접힘 상태 (uncontrolled) */ defaultCollapsed?: boolean; onCollapsedChange?: (collapsed: boolean) => void; children?: React.ReactNode;}function SideNavigationProvider({ collapsed: collapsedProp, defaultCollapsed = false, onCollapsedChange, children,}: SideNavigationProviderProps) { const [uncontrolled, setUncontrolled] = React.useState(defaultCollapsed); const collapsed = collapsedProp ?? uncontrolled; const collapsedRef = React.useRef(collapsed); collapsedRef.current = collapsed; const setCollapsed = React.useCallback<SideNavigationContextValue['setCollapsed']>( (next) => { const resolved = typeof next === 'function' ? next(collapsedRef.current) : next; if (collapsedProp === undefined) setUncontrolled(resolved); onCollapsedChange?.(resolved); }, [collapsedProp, onCollapsedChange], ); const value = React.useMemo<SideNavigationContextValue>( () => ({ collapsed, setCollapsed, toggle: () => setCollapsed((prev) => !prev) }), [collapsed, setCollapsed], ); return ( <SideNavigationContext.Provider value={value}> <TooltipPrimitive.Provider delayDuration={0}>{children}</TooltipPrimitive.Provider> </SideNavigationContext.Provider> );}////////////////////////////////////////////////////////////////////////////////////// Root / Header / Content / Footer / Insetconst sideNavigationVariants = cva( [ 'group/side-navigation relative flex h-full w-[240px] shrink-0 flex-col overflow-x-hidden', 'transition-[width] duration-(--rb-duration-d4)', 'data-[side-navigation-state=collapsed]:w-[56px]', ], { variants: { tone: { neutral: 'bg-palette-gray-100 shadow-[inset_-1px_0_0_0_var(--rb-color-stroke-neutral-muted)]', transparent: '', }, }, defaultVariants: { tone: 'neutral', }, },);export interface SideNavigationRootProps extends React.ComponentProps<'nav'>, VariantProps<typeof sideNavigationVariants> {}function SideNavigationRoot(props: SideNavigationRootProps) { const context = React.useContext(SideNavigationContext); // Provider 없이 쓰면 내부 상태로 동작해요. if (!context) { return ( <SideNavigationProvider> <SideNavigationRootImpl {...props} /> </SideNavigationProvider> ); } return <SideNavigationRootImpl {...props} />;}function SideNavigationRootImpl({ className, tone, ...props }: SideNavigationRootProps) { const { collapsed } = useSideNavigation(); return ( <nav data-slot="side-navigation" data-side-navigation-state={collapsed ? 'collapsed' : 'expanded'} className={cn(sideNavigationVariants({ tone }), className)} {...props} /> );}export interface SideNavigationHeaderProps extends React.ComponentProps<'div'> {}function SideNavigationHeader({ className, ...props }: SideNavigationHeaderProps) { return ( <div data-slot="side-navigation-header" className={cn('box-border min-h-[64px] shrink-0 p-x2', className)} {...props} /> );}export interface SideNavigationContentProps extends React.ComponentProps<'div'> {}function SideNavigationContent({ className, onScroll, ...props }: SideNavigationContentProps) { const [scrolled, setScrolled] = React.useState(false); return ( <div data-slot="side-navigation-content" data-scrolled={scrolled || undefined} onScroll={(event) => { setScrolled(event.currentTarget.scrollTop > 0); onScroll?.(event); }} className={cn( 'flex flex-1 flex-col gap-x2 overflow-y-auto px-x2 pt-x2 pb-x6', 'transition-[gap,box-shadow] duration-(--rb-duration-d4)', // 스크롤 시 상단 구분선 'data-[scrolled]:shadow-[inset_0_1px_0_0_var(--rb-color-stroke-neutral-muted)]', // 하단 Scroll Fog (24px = paddingBottom) '[mask-image:linear-gradient(to_top,transparent_0,black_24px)]', '[scrollbar-color:var(--rb-color-bg-neutral-weak-alpha)_transparent] [scrollbar-width:thin]', 'group-data-[side-navigation-state=collapsed]/side-navigation:gap-0 group-data-[side-navigation-state=collapsed]/side-navigation:[scrollbar-width:none]', className, )} {...props} /> );}export interface SideNavigationFooterProps extends React.ComponentProps<'div'> {}function SideNavigationFooter({ className, ...props }: SideNavigationFooterProps) { return <div data-slot="side-navigation-footer" className={cn('shrink-0 p-x2 empty:hidden', className)} {...props} />;}export interface SideNavigationInsetProps extends React.ComponentProps<'main'> {}/** Side Navigation 옆의 메인 콘텐츠 영역이에요. */function SideNavigationInset({ className, ...props }: SideNavigationInsetProps) { return <main data-slot="side-navigation-inset" className={cn('grow overflow-y-auto', className)} {...props} />;}////////////////////////////////////////////////////////////////////////////////////// Triggerexport interface SideNavigationTriggerProps extends Omit<React.ComponentProps<'button'>, 'children'> {}function SideNavigationTrigger({ className, onClick, ...props }: SideNavigationTriggerProps) { const { collapsed, toggle } = useSideNavigation(); return ( <button type="button" data-slot="side-navigation-trigger" aria-label={collapsed ? '사이드바 열기' : '사이드바 닫기'} aria-expanded={!collapsed} onClick={(event) => { onClick?.(event); if (!event.defaultPrevented) toggle(); }} className={cn( 'absolute top-x3 right-x3 inline-flex min-h-x10 min-w-x10 cursor-pointer items-center justify-center rounded-r2 border-none bg-transparent p-[10px]', 'text-fg-neutral-subtle [&_svg]:size-[18px] [&_svg]:shrink-0', 'transition-[right,background-color,outline-color] duration-(--rb-duration-d4)', 'outline-2 outline-offset-2 outline-transparent focus-visible:outline-stroke-focus-ring', 'active:bg-bg-transparent-pressed', 'group-data-[side-navigation-state=collapsed]/side-navigation:right-x2', className, )} {...props} > <PanelLeft /> </button> );}////////////////////////////////////////////////////////////////////////////////////// Groupexport interface SideNavigationGroupProps extends React.ComponentProps<'div'> { label?: React.ReactNode;}function SideNavigationGroup({ className, label, children, ...props }: SideNavigationGroupProps) { return ( <div data-slot="side-navigation-group" role="group" className={cn('flex flex-col', className)} {...props}> {label && <SideNavigationGroupLabel>{label}</SideNavigationGroupLabel>} {children} </div> );}export interface SideNavigationGroupLabelProps extends React.ComponentProps<'div'> {}function SideNavigationGroupLabel({ className, ...props }: SideNavigationGroupLabelProps) { return ( <div data-slot="side-navigation-group-label" className={cn( 'pointer-events-none truncate p-x1_5 text-t4 font-bold text-fg-neutral-muted opacity-100', 'transition-[margin,opacity] duration-(--rb-duration-d4)', // 접힘: label 높이 + 상하 패딩만큼 음수 마진으로 숨겨요. 'group-data-[side-navigation-state=collapsed]/side-navigation:mt-[calc(-1*(1lh_+_12px))] group-data-[side-navigation-state=collapsed]/side-navigation:opacity-0', className, )} {...props} /> );}////////////////////////////////////////////////////////////////////////////////////// Itemconst sideNavigationItemClassName = cn( 'group/side-navigation-item relative flex h-[44px] w-full items-center gap-x3 overflow-hidden rounded-[10px] border-none bg-transparent px-x2 text-left outline-none', 'scroll-mt-x2 scroll-mb-x6', 'transition-[padding,background-color] duration-(--rb-duration-d4)', 'enabled:cursor-pointer disabled:cursor-not-allowed aria-disabled:cursor-not-allowed', // 눌림 레이어 + 안쪽 포커스 링 "before:absolute before:inset-0 before:rounded-[10px] before:outline-2 before:-outline-offset-2 before:outline-transparent before:content-['']", 'before:transition-[background-color,outline-color] before:duration-(--rb-duration-d4)', 'focus-visible:before:outline-stroke-focus-ring', 'enabled:not-data-[current]:active:before:bg-bg-transparent-pressed', 'data-[current]:bg-bg-transparent-selected data-[current]:enabled:active:bg-bg-transparent-selected-pressed', // 접힘: 56px - 8px*2 = 40px 'group-data-[side-navigation-state=collapsed]/side-navigation:px-[10px] group-data-[side-navigation-state=collapsed]/side-navigation:before:right-auto group-data-[side-navigation-state=collapsed]/side-navigation:before:w-x10',);function ItemPrefixIcon({ children }: { children: React.ReactNode }) { return ( <span data-slot="side-navigation-item-prefix-icon" aria-hidden className={cn( 'absolute top-1/2 inline-flex size-x5 shrink-0 -translate-y-1/2 items-center justify-center text-palette-gray-600 transition-[color] duration-(--rb-duration-d4) [&_svg]:size-full', 'group-data-[current]/side-navigation-item:text-fg-neutral group-disabled/side-navigation-item:text-fg-disabled', )} > {children} </span> );}function ItemLabel({ children }: { children: React.ReactNode }) { return ( <span data-slot="side-navigation-item-label" className={cn( // prefixIcon(20px) + gap(12px) 만큼 왼쪽 여백 'min-w-0 grow truncate p-x1_5 pl-[32px] text-t4 font-medium text-fg-neutral-muted', 'transition-[opacity,color] duration-(--rb-duration-d4)', 'group-data-[current]/side-navigation-item:text-fg-neutral group-disabled/side-navigation-item:text-fg-disabled', 'group-data-[side-navigation-state=collapsed]/side-navigation:opacity-0', )} > {children} </span> );}function ItemSuffixIcon({ children }: { children: React.ReactNode }) { return ( <span data-slot="side-navigation-item-suffix-icon" aria-hidden className={cn( 'inline-flex size-x4 shrink-0 items-center justify-center text-fg-neutral-subtle [&_svg]:size-full', 'transition-[transform,opacity] duration-(--rb-duration-d4)', 'group-data-[state=closed]/side-navigation-item:rotate-180', 'group-data-[side-navigation-state=collapsed]/side-navigation:opacity-0', )} > {children} </span> );}function ItemTooltip({ label, children }: { label: React.ReactNode; children: React.ReactNode }) { return ( <TooltipPrimitive.Root> <TooltipPrimitive.Trigger asChild>{children}</TooltipPrimitive.Trigger> <TooltipPrimitive.Portal> <TooltipPrimitive.Content side="right" sideOffset={8} className={cn( 'z-50 max-w-[240px] rounded-r2 bg-bg-neutral-inverted px-x3 py-x1_5 text-t3 font-medium text-fg-neutral-inverted shadow-s2', 'data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in data-[state=closed]:animate-out data-[state=closed]:fade-out', )} > {label} </TooltipPrimitive.Content> </TooltipPrimitive.Portal> </TooltipPrimitive.Root> );}export interface SideNavigationItemProps extends Omit<React.ComponentProps<'button'>, 'children' | 'prefix'> { label: React.ReactNode; prefixIcon?: React.ReactNode; suffixIcon?: React.ReactNode; /** 현재 페이지 표시 */ current?: boolean; /** 링크 등 자식 요소에 스타일을 입혀요. 자식의 children 은 무시하고 label 을 렌더링해요. */ asChild?: boolean; /** 접힌 상태에서 라벨 툴팁을 꺼요. */ disableTooltip?: boolean;}function SideNavigationItem({ className, label, prefixIcon, suffixIcon, current, asChild = false, disableTooltip = false, children, ...props}: SideNavigationItemProps & { children?: React.ReactElement }) { const { collapsed } = useSideNavigation(); const Comp = asChild ? Slot.Root : 'button'; const content = ( <> {prefixIcon && <ItemPrefixIcon>{prefixIcon}</ItemPrefixIcon>} <ItemLabel>{label}</ItemLabel> {suffixIcon && <ItemSuffixIcon>{suffixIcon}</ItemSuffixIcon>} </> ); const item = ( <Comp type={asChild ? undefined : 'button'} data-slot="side-navigation-item" data-current={current || undefined} aria-current={current ? 'page' : undefined} className={cn(sideNavigationItemClassName, className)} {...props} > {asChild && React.isValidElement<{ children?: React.ReactNode }>(children) ? React.cloneElement(children, undefined, content) : content} </Comp> ); if (!collapsed || disableTooltip) return item; return <ItemTooltip label={label}>{item}</ItemTooltip>;}////////////////////////////////////////////////////////////////////////////////////// Collapsible item (하위 메뉴)export interface SideNavigationSubItem { key?: React.Key; label: React.ReactNode; current?: boolean; disabled?: boolean; onClick?: React.MouseEventHandler<HTMLButtonElement>;}export interface SideNavigationCollapsibleItemProps extends Omit<React.ComponentProps<typeof CollapsiblePrimitive.Root>, 'children'> { label: React.ReactNode; prefixIcon?: React.ReactNode; items: SideNavigationSubItem[];}/** * 하위 항목이 있는 메뉴예요. 부모를 누르면 페이지 이동 없이 하위 리스트만 접고 펼쳐요. * 하위 항목 중 current 가 있으면 자동으로 펼쳐지고, 사이드바가 접히면 팝오버로 하위 메뉴를 보여줘요. */function SideNavigationCollapsibleItem({ label, prefixIcon, items, open: openProp, defaultOpen, onOpenChange, ...props}: SideNavigationCollapsibleItemProps) { const { collapsed } = useSideNavigation(); const hasCurrentChild = items.some((sub) => sub.current); const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen ?? hasCurrentChild); const open = openProp ?? uncontrolledOpen; const [flyoutOpen, setFlyoutOpen] = React.useState(false); const handleOpenChange = (next: boolean) => { if (openProp === undefined) setUncontrolledOpen(next); onOpenChange?.(next); }; if (collapsed) { return ( <PopoverPrimitive.Root open={flyoutOpen} onOpenChange={setFlyoutOpen}> <PopoverPrimitive.Trigger asChild> <SideNavigationItem label={label} prefixIcon={prefixIcon} current={hasCurrentChild} disableTooltip /> </PopoverPrimitive.Trigger> <PopoverPrimitive.Portal> <PopoverPrimitive.Content side="right" align="start" sideOffset={8} className={cn( 'z-50 flex min-w-[200px] flex-col rounded-r3 bg-bg-layer-floating p-x2 shadow-s2 outline-none', 'data-[state=open]:animate-in data-[state=open]:fade-in data-[state=closed]:animate-out data-[state=closed]:fade-out', )} > <div className="px-x2 py-x1_5 text-t3 font-bold text-fg-neutral-muted">{label}</div> {items.map((sub, index) => ( <button key={sub.key ?? index} type="button" disabled={sub.disabled} data-current={sub.current || undefined} aria-current={sub.current ? 'page' : undefined} onClick={(event) => { sub.onClick?.(event); setFlyoutOpen(false); }} className={cn( 'flex min-h-x10 w-full cursor-pointer items-center rounded-r2 px-x2 text-left text-t4 font-medium text-fg-neutral-muted', 'transition-[background-color] duration-(--rb-duration-color-transition) ease-easing', 'outline-2 -outline-offset-2 outline-transparent focus-visible:outline-stroke-focus-ring', 'enabled:active:bg-bg-transparent-pressed disabled:cursor-not-allowed disabled:text-fg-disabled', 'data-[current]:bg-bg-transparent-selected data-[current]:text-fg-neutral', )} > {sub.label} </button> ))} </PopoverPrimitive.Content> </PopoverPrimitive.Portal> </PopoverPrimitive.Root> ); } return ( <CollapsiblePrimitive.Root data-slot="side-navigation-collapsible" open={open} onOpenChange={handleOpenChange} {...props} > <CollapsiblePrimitive.Trigger asChild> <SideNavigationItem label={label} prefixIcon={prefixIcon} suffixIcon={<ChevronUp />} disableTooltip /> </CollapsiblePrimitive.Trigger> <CollapsiblePrimitive.Content data-slot="side-navigation-collapsible-content" className={cn( 'overflow-hidden duration-(--rb-duration-d4)', 'data-[state=open]:animate-collapsible-down data-[state=closed]:animate-collapsible-up', )} > {items.map((sub, index) => ( <SideNavigationItem key={sub.key ?? index} label={sub.label} current={sub.current} disabled={sub.disabled} onClick={sub.onClick} /> ))} </CollapsiblePrimitive.Content> </CollapsiblePrimitive.Root> );}export { SideNavigationProvider, SideNavigationRoot, SideNavigationHeader, SideNavigationContent, SideNavigationFooter, SideNavigationInset, SideNavigationTrigger, SideNavigationGroup, SideNavigationGroupLabel, SideNavigationItem, SideNavigationCollapsibleItem, useSideNavigation, sideNavigationVariants,};