/** * @title Drawer * @description 화면 왼쪽에서 밀려 나와 현재 페이지를 떠나지 않고 보조 작업을 하게 해주는 패널이에요. */import * as React from 'react';import { Dialog as DialogPrimitive } from 'radix-ui';import { ArrowLeft } from 'lucide-react';import { cn } from '@/lib/utils';import { IconButton } from '@/components/ui/button';// Atlassian @atlaskit/drawer 14 스펙// - panel: position fixed · top 0 · left 0 · height 100vh · display flex · overflow hidden// background elevation.surface.overlay · z-index 500// - width: narrow 360px · medium 480px(5in) · wide 600px · extended 95vw · full 100vw// - sidebar: 64px(space.800) 폭 · 세로 flex · 가운데 정렬 · 위 24px / 아래 16px · color.text.subtle// - content: flex 1 1 0 · overflow auto · margin-top 24px// - 진입: enterFrom 방향에서 슬라이드(ease-out), 블랭킷은 tinted 페이드export type DrawerWidth = 'extended' | 'full' | 'medium' | 'narrow' | 'wide';export type Direction = 'left' | 'right' | 'top' | 'bottom';export type CloseTrigger = 'backButton' | 'blanket' | 'escKey';const OnCloseContext = React.createContext<((event: React.SyntheticEvent<HTMLElement>) => void) | undefined>( undefined,);export interface FocusLockSettings { /** 열릴 때 첫 번째 탭 가능한 요소로 포커스를 옮겨요. 기본값은 `true` 예요. */ autoFocusFirstElem?: boolean; /** 닫힐 때까지 포커스를 드로어 안에 가둬요. 보조 기술 사용자를 위해 켜 두는 걸 권장해요. */ isFocusLockEnabled?: boolean; /** 닫힌 뒤 포커스를 트리거로 돌려줄지. ref 를 주면 그 요소로 돌려줘요. */ shouldReturnFocus?: boolean | React.RefObject<HTMLElement | null>;}export interface DrawerProps extends FocusLockSettings { /** 드로어가 열려 있는지 */ isOpen: boolean; children?: React.ReactNode; /** 드로어 너비 */ width?: DrawerWidth; /** 드로어가 들어오는 방향. 기본값은 `left` 예요. */ enterFrom?: Direction; /** 닫혀야 할 때(Esc, 블랭킷 클릭, 닫기 버튼) 호출돼요. */ onClose?: (event: React.SyntheticEvent<HTMLElement> | KeyboardEvent, trigger: CloseTrigger) => void; /** 드로어가 열려 있는 동안 keydown 이벤트마다 호출돼요. */ onKeyDown?: (event: KeyboardEvent) => void; /** 열림 전환이 끝나면 호출돼요. */ onOpenComplete?: (node: HTMLElement | null) => void; /** 닫힘 전환이 끝나면 호출돼요. */ onCloseComplete?: (node: HTMLElement | null) => void; /** 보조 기술용 이름(aria-label). 보이는 제목이 없을 때 써요. */ label?: string; /** 보이는 제목 요소의 id (aria-labelledby). */ titleId?: string; /** 포털의 z-index. 기본값은 `unset` 이에요. */ zIndex?: number | 'unset'; testId?: string; className?: string;}const panelWidth: Record<DrawerWidth, string> = { narrow: 'w-[360px]', medium: 'w-[480px]', wide: 'w-[600px]', extended: 'w-[95vw]', full: 'w-screen',};const enterFromClass: Record<Direction, string> = { left: 'start-0 data-[state=open]:slide-in-from-left data-[state=closed]:slide-out-to-left', right: 'end-0 data-[state=open]:slide-in-from-right data-[state=closed]:slide-out-to-right', top: 'start-0 data-[state=open]:slide-in-from-top data-[state=closed]:slide-out-to-top', bottom: 'start-0 data-[state=open]:slide-in-from-bottom data-[state=closed]:slide-out-to-bottom',};/** 화면 옆에서 밀려 나오는 모달 패널이에요. `DrawerSidebar`, `DrawerContent` 를 조합해 구성해요. */function Drawer({ isOpen, children, width = 'narrow', enterFrom = 'left', onClose, onKeyDown, onOpenComplete, onCloseComplete, label, titleId, zIndex = 'unset', autoFocusFirstElem = true, isFocusLockEnabled = true, shouldReturnFocus = true, testId, className,}: DrawerProps) { const mouseDownTarget = React.useRef<EventTarget | null>(null); // 열려 있는 동안 window keydown 을 그대로 전달 React.useEffect(() => { if (!isOpen || !onKeyDown) return; window.addEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown); }, [isOpen, onKeyDown]); const handleBackButton = React.useCallback( (event: React.SyntheticEvent<HTMLElement>) => onClose?.(event, 'backButton'), [onClose], ); const layerStyle = zIndex === 'unset' ? undefined : { zIndex }; const blanketClass = cn( 'fixed inset-0 z-[500] bg-ds-blanket', 'data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:duration-350', 'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:duration-175', 'ease-out motion-reduce:animate-none', ); const blanketHandlers = { onMouseDown: (event: React.MouseEvent<HTMLDivElement>) => { mouseDownTarget.current = event.target; }, onClick: (event: React.MouseEvent<HTMLDivElement>) => { if (event.target === event.currentTarget && mouseDownTarget.current === event.target) { onClose?.(event, 'blanket'); } }, }; return ( <DialogPrimitive.Root open={isOpen} modal={isFocusLockEnabled} onOpenChange={() => {}}> <DialogPrimitive.Portal> {isFocusLockEnabled ? ( <DialogPrimitive.Overlay data-slot="drawer-blanket" data-testid={testId && `${testId}--blanket`} style={layerStyle} className={blanketClass} {...blanketHandlers} /> ) : ( isOpen && ( <div role="presentation" data-slot="drawer-blanket" data-state="open" data-testid={testId && `${testId}--blanket`} style={layerStyle} className={blanketClass} {...blanketHandlers} /> ) )} <DialogPrimitive.Title hidden>{label}</DialogPrimitive.Title> <DialogPrimitive.Content data-slot="drawer" data-testid={testId} data-width={width} aria-modal={isFocusLockEnabled || undefined} aria-label={label || (titleId ? undefined : '드로어')} aria-labelledby={label ? undefined : titleId} aria-describedby={undefined} style={layerStyle} onOpenAutoFocus={(event) => { if (autoFocusFirstElem) return; event.preventDefault(); (event.currentTarget as HTMLElement | null)?.focus(); }} onCloseAutoFocus={(event) => { if (shouldReturnFocus === true) return; event.preventDefault(); if (typeof shouldReturnFocus === 'object') shouldReturnFocus.current?.focus(); }} onEscapeKeyDown={(event) => { event.preventDefault(); onClose?.(event, 'escKey'); }} onPointerDownOutside={(event) => event.preventDefault()} onInteractOutside={(event) => event.preventDefault()} onAnimationEnd={(event) => { if (event.target !== event.currentTarget) return; const node = event.currentTarget; if (node.dataset.state === 'open') onOpenComplete?.(node); else onCloseComplete?.(node); }} className={cn( 'fixed top-0 z-[500] flex h-dvh max-w-screen overflow-hidden outline-none', 'bg-ds-surface-overlay font-ds-body text-ds-text [--ds-elevation-surface-current:var(--ds-surface-overlay)]', panelWidth[width], enterFromClass[enterFrom], 'data-[state=open]:animate-in data-[state=open]:duration-200 data-[state=closed]:animate-out data-[state=closed]:duration-150', 'ease-out motion-reduce:animate-none', className, )} > <OnCloseContext.Provider value={handleBackButton}>{children}</OnCloseContext.Provider> </DialogPrimitive.Content> </DialogPrimitive.Portal> </DialogPrimitive.Root> );}export interface DrawerSidebarProps extends React.ComponentProps<'div'> { children?: React.ReactNode;}/** 드로어 왼쪽의 64px 사이드바예요. 보통 닫기 버튼을 넣어요. */function DrawerSidebar({ children, className, ...props }: DrawerSidebarProps) { return ( <div data-slot="drawer-sidebar" data-testid="drawer-sidebar" className={cn( 'box-border flex h-dvh w-ds-800 shrink-0 flex-col items-center pt-ds-300 pb-ds-200 text-ds-text-subtle', className, )} {...props} > {children} </div> );}export interface DrawerContentProps extends React.ComponentProps<'div'> { children?: React.ReactNode; /** 내용이 스크롤될 때 영역의 보조 기술용 이름. 기본값은 "스크롤 가능한 내용" 이에요. */ scrollContentLabel?: string;}/** 드로어의 본문 영역이에요. 넘치면 스스로 스크롤돼요. */function DrawerContent({ children, scrollContentLabel = '스크롤 가능한 내용', className, ...props }: DrawerContentProps) { const ref = React.useRef<HTMLDivElement>(null); const [showContentFocus, setContentFocus] = React.useState(false); React.useEffect(() => { const target = ref.current; if (!target) return; const update = () => setContentFocus(target.scrollHeight > target.clientHeight); update(); const observer = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(update) : null; observer?.observe(target); return () => observer?.disconnect(); }, []); return ( <div ref={ref} data-slot="drawer-content" data-testid="drawer-contents" // 넘칠 때만 키보드로 스크롤할 수 있게 포커스 가능 tabIndex={showContentFocus ? 0 : undefined} role={showContentFocus ? 'region' : undefined} aria-label={showContentFocus ? scrollContentLabel : undefined} className={cn( 'mt-ds-300 min-w-0 flex-1 basis-0 overflow-auto', 'outline-2 -outline-offset-2 outline-transparent focus-visible:outline-ds-border-focused', className, )} {...props} > {children} </div> );}export interface DrawerCloseButtonProps { /** 닫기/뒤로 버튼 아이콘. 기본값은 왼쪽 화살표예요. */ icon?: React.ComponentType<{ className?: string }>; /** 보조 기술용 이름. 기본값은 "드로어 닫기" 예요. */ label?: string; testId?: string;}/** 드로어를 닫는 원형 버튼이에요. Drawer 안에서만 써요. */function DrawerCloseButton({ icon: Icon, label = '드로어 닫기', testId = 'DrawerCloseButton' }: DrawerCloseButtonProps) { const onClose = React.useContext(OnCloseContext); return ( <IconButton data-slot="drawer-close-button" data-testid={testId} appearance="subtle" shape="circle" label={label} icon={Icon ? <Icon /> : <ArrowLeft />} onClick={(event) => onClose?.(event)} /> );}export { Drawer, DrawerSidebar, DrawerContent, DrawerCloseButton };export default Drawer;