/** * @title Menu Sheet * @description 사용자의 작업과 관련된 선택지를 시트 형태로 제공하는 컴포넌트예요. */'use client';import * as React from 'react';import { Drawer as DrawerPrimitive } from 'vaul';import { cva, type VariantProps } from 'class-variance-authority';import { cn } from '@/lib/utils';// SEED Rootage spec: menu-sheet.yaml, menu-sheet-item.yaml, menu-sheet-close-button.yamltype LabelAlign = 'left' | 'center';const LabelAlignContext = React.createContext<LabelAlign>('left');type MenuSheetProps = React.ComponentProps<typeof DrawerPrimitive.Root>;function MenuSheet(props: MenuSheetProps) { return <DrawerPrimitive.Root data-slot="menu-sheet" {...props} />;}function MenuSheetTrigger(props: React.ComponentProps<typeof DrawerPrimitive.Trigger>) { return <DrawerPrimitive.Trigger data-slot="menu-sheet-trigger" {...props} />;}function MenuSheetClose(props: React.ComponentProps<typeof DrawerPrimitive.Close>) { return <DrawerPrimitive.Close data-slot="menu-sheet-close" {...props} />;}function MenuSheetBackdrop({ className, ...props }: React.ComponentProps<typeof DrawerPrimitive.Overlay>) { return ( <DrawerPrimitive.Overlay data-slot="menu-sheet-backdrop" className={cn( 'fixed inset-0 z-50 bg-bg-overlay', // backdrop: enter d2 / enter, exit d2 / exit 'data-[state=open]:[animation-duration:var(--rb-duration-d2)]! data-[state=open]:[animation-timing-function:var(--rb-timing-function-enter)]!', 'data-[state=closed]:[animation-duration:var(--rb-duration-d2)]! data-[state=closed]:[animation-timing-function:var(--rb-timing-function-exit)]!', className, )} {...props} /> );}interface MenuSheetContentProps extends Omit<React.ComponentProps<typeof DrawerPrimitive.Content>, 'title'> { title?: React.ReactNode; description?: React.ReactNode; /** 하위 아이템 라벨 정렬의 기본값이에요. Group·Item 에서 덮어쓸 수 있어요. */ labelAlign?: LabelAlign; /** 드래그 핸들을 보여줘요. @default true */ showHandle?: boolean; /** 닫기 버튼 라벨이에요. @default '닫기' */ closeLabel?: React.ReactNode;}function MenuSheetContent({ className, children, title, description, labelAlign = 'left', showHandle = true, closeLabel = '닫기', ...props}: MenuSheetContentProps) { return ( <DrawerPrimitive.Portal> <MenuSheetBackdrop /> <DrawerPrimitive.Content data-slot="menu-sheet-content" className={cn( 'fixed inset-x-0 bottom-0 z-50 mx-auto flex w-full max-w-[480px] flex-col break-all outline-none', 'max-h-[min(90dvh,100dvh_-_env(safe-area-inset-top))] rounded-t-r5 bg-bg-layer-floating px-spacing-x-global-gutter', 'pb-[calc(var(--rb-dimension-x4)_+_env(safe-area-inset-bottom))]', showHandle ? 'pt-x6' : 'pt-x4', // content: enter d6 / enter-expressive, exit d4 / exit 'data-[state=open]:[animation-duration:var(--rb-duration-d6)]! data-[state=open]:[animation-timing-function:var(--rb-timing-function-enter-expressive)]!', 'data-[state=closed]:[animation-duration:var(--rb-duration-d4)]! data-[state=closed]:[animation-timing-function:var(--rb-timing-function-exit)]!', className, )} {...props} > {showHandle && ( <DrawerPrimitive.Handle data-slot="menu-sheet-handle" className="absolute! top-[6px]! left-1/2! m-0! h-[4px]! w-[36px]! -translate-x-1/2 rounded-full! bg-palette-gray-400! opacity-100! transition-[background-color] duration-(--rb-duration-color-transition) ease-easing active:bg-palette-gray-500!" /> )} {title || description ? ( <MenuSheetHeader> {title ? ( <MenuSheetTitle>{title}</MenuSheetTitle> ) : ( <DrawerPrimitive.Title className="sr-only">{props['aria-label'] ?? ''}</DrawerPrimitive.Title> )} {description && <MenuSheetDescription>{description}</MenuSheetDescription>} </MenuSheetHeader> ) : ( <DrawerPrimitive.Title className="sr-only">{props['aria-label'] ?? ''}</DrawerPrimitive.Title> )} <LabelAlignContext.Provider value={labelAlign}> <MenuSheetList>{children}</MenuSheetList> </LabelAlignContext.Provider> <MenuSheetFooter> <MenuSheetCloseButton>{closeLabel}</MenuSheetCloseButton> </MenuSheetFooter> </DrawerPrimitive.Content> </DrawerPrimitive.Portal> );}function MenuSheetHeader({ className, ...props }: React.ComponentProps<'div'>) { return ( <div data-slot="menu-sheet-header" className={cn('flex flex-col items-center gap-x1 pb-x4 text-center', className)} {...props} /> );}function MenuSheetTitle({ className, ...props }: React.ComponentProps<typeof DrawerPrimitive.Title>) { return ( <DrawerPrimitive.Title data-slot="menu-sheet-title" className={cn('m-0 text-t6 font-bold text-fg-neutral', className)} {...props} /> );}function MenuSheetDescription({ className, ...props }: React.ComponentProps<typeof DrawerPrimitive.Description>) { return ( <DrawerPrimitive.Description data-slot="menu-sheet-description" className={cn('m-0 text-t4 font-regular text-fg-neutral-muted', className)} {...props} /> );}function MenuSheetList({ className, ...props }: React.ComponentProps<'div'>) { return ( <div data-slot="menu-sheet-list" className={cn('flex min-h-0 flex-col items-stretch gap-x2_5 overflow-y-auto', className)} {...props} /> );}interface MenuSheetGroupProps extends React.ComponentProps<'div'> { /** 그룹 안 아이템의 라벨 정렬이에요. 지정하지 않으면 Content 의 값을 따라요. */ labelAlign?: LabelAlign;}function MenuSheetGroup({ className, labelAlign, ...props }: MenuSheetGroupProps) { const parent = React.useContext(LabelAlignContext); return ( <LabelAlignContext.Provider value={labelAlign ?? parent}> <div role="group" data-slot="menu-sheet-group" className={cn('flex shrink-0 flex-col items-stretch overflow-hidden rounded-r4', className)} {...props} /> </LabelAlignContext.Provider> );}const menuSheetItemVariants = cva( [ 'group/menu-sheet-item relative m-0 flex min-h-[52px] w-full cursor-pointer items-center gap-x3_5 border-none px-x4 py-x3_5 text-left font-[inherit]', 'bg-bg-neutral-weak shadow-[inset_0_-1px_0_var(--rb-color-stroke-neutral-muted)] last:shadow-none', 'first:rounded-t-r4 last:rounded-b-r4', 'transition-[background-color,outline-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-neutral-weak-pressed', 'disabled:cursor-not-allowed disabled:text-fg-disabled', '[&_svg]:pointer-events-none [&_svg]:size-[22px] [&_svg]:shrink-0', ], { variants: { tone: { neutral: 'text-fg-neutral [&_svg]:text-fg-neutral', critical: 'text-fg-critical [&_svg]:text-fg-critical', }, labelAlign: { left: '', center: 'justify-center', }, }, defaultVariants: { tone: 'neutral', labelAlign: 'left' }, },);interface MenuSheetItemProps extends Omit<React.ComponentProps<'button'>, 'children'>, VariantProps<typeof menuSheetItemVariants> { label: React.ReactNode; description?: React.ReactNode; prefixIcon?: React.ReactNode; /** 누르면 시트를 닫아요. @default true */ closeOnClick?: boolean;}function MenuSheetItem({ className, tone, labelAlign, label, description, prefixIcon, closeOnClick = true, type = 'button', ...props}: MenuSheetItemProps) { const inherited = React.useContext(LabelAlignContext); const align = labelAlign ?? inherited; const item = ( <button type={type} data-slot="menu-sheet-item" data-tone={tone ?? 'neutral'} className={cn(menuSheetItemVariants({ tone, labelAlign: align }), className)} {...props} > {prefixIcon} {/* SEED scaleScope: content — 눌렀을 때 배경 대신 내용이 줄어들어요. */} <span data-slot="menu-sheet-item-content" className={cn( 'flex flex-col gap-x0_5 transition-[scale] duration-(--rb-duration-pressed-scale) ease-pressed-scale group-active/menu-sheet-item:scale-[0.97] group-disabled/menu-sheet-item:scale-100', align === 'center' ? 'items-center text-center' : 'text-start', )} > <span data-slot="menu-sheet-item-label" className="text-t5 font-regular"> {label} </span> {description && ( <span data-slot="menu-sheet-item-description" className="text-t3 font-medium text-fg-neutral-subtle"> {description} </span> )} </span> </button> ); return closeOnClick ? <DrawerPrimitive.Close asChild>{item}</DrawerPrimitive.Close> : item;}function MenuSheetFooter({ className, ...props }: React.ComponentProps<'div'>) { return ( <div data-slot="menu-sheet-footer" className={cn('flex flex-col items-stretch pt-x2_5', className)} {...props} /> );}function MenuSheetCloseButton({ className, ...props }: React.ComponentProps<typeof DrawerPrimitive.Close>) { return ( <DrawerPrimitive.Close data-slot="menu-sheet-close-button" className={cn( 'flex min-h-[52px] cursor-pointer items-center justify-center rounded-r3 border-none bg-bg-neutral-weak px-x5 py-x3_5 font-[inherit]', 'text-t5 font-medium text-fg-neutral', 'transition-[background-color,scale,outline-color] duration-(--rb-duration-color-transition) ease-easing', 'outline-2 outline-offset-2 outline-transparent focus-visible:outline-stroke-focus-ring', 'active:scale-[0.97] active:bg-bg-neutral-weak-pressed', className, )} {...props} /> );}export { MenuSheet, MenuSheetTrigger, MenuSheetClose, MenuSheetBackdrop, MenuSheetContent, MenuSheetHeader, MenuSheetTitle, MenuSheetDescription, MenuSheetList, MenuSheetGroup, MenuSheetItem, MenuSheetFooter, MenuSheetCloseButton, menuSheetItemVariants,};export type { MenuSheetProps, MenuSheetContentProps, MenuSheetGroupProps, MenuSheetItemProps };