/** * @title Menu * @description 사용자가 취할 수 있는 선택지나 액션 목록을 트리거 주변에 띄워 보여주는 컴포넌트예요. */'use client';import * as React from 'react';import { DropdownMenu as MenuPrimitive } from 'radix-ui';import { cva } from 'class-variance-authority';import { cn } from '@/lib/utils';// SEED Rootage spec: menu.yaml, menu-item.yamltype MenuSide = 'top' | 'bottom' | 'left' | 'right';type MenuAlign = 'start' | 'center' | 'end';export type MenuPlacement = MenuSide | `${MenuSide}-${Exclude<MenuAlign, 'center'>}`;export type MenuSize = 'medium' | 'small' | 'responsive';function parsePlacement(placement: MenuPlacement): { side: MenuSide; align: MenuAlign } { const [side, align] = placement.split('-') as [MenuSide, MenuAlign | undefined]; return { side, align: align ?? 'center' };}interface MenuContextValue { size: MenuSize; placement: MenuPlacement; matchReferenceWidth: boolean;}const MenuContext = React.createContext<MenuContextValue>({ size: 'medium', placement: 'bottom', matchReferenceWidth: false,});export interface MenuRootProps extends React.ComponentProps<typeof MenuPrimitive.Root> { /** * `medium` 은 모든 뷰포트에서, `small` 은 `lg` 이상(데스크톱)에서만 사용해요. * `responsive` 는 `lg` 미만에서 `medium`, `lg` 이상에서 `small` 로 적용돼요. */ size?: MenuSize; /** 트리거 기준 위치예요. `{side}-{align}` 조합이에요. */ placement?: MenuPlacement; /** 메뉴 너비를 트리거 너비에 맞춰요. */ matchReferenceWidth?: boolean;}function MenuRoot({ size = 'medium', placement = 'bottom', matchReferenceWidth = false, ...props }: MenuRootProps) { const value = React.useMemo( () => ({ size, placement, matchReferenceWidth }), [size, placement, matchReferenceWidth], ); return ( <MenuContext.Provider value={value}> <MenuPrimitive.Root data-slot="menu" {...props} /> </MenuContext.Provider> );}export interface MenuTriggerProps extends React.ComponentProps<typeof MenuPrimitive.Trigger> {}function MenuTrigger(props: MenuTriggerProps) { return <MenuPrimitive.Trigger data-slot="menu-trigger" {...props} />;}const menuContentVariants = cva( [ 'z-[99999] overflow-hidden rounded-r5 bg-bg-layer-floating shadow-s3 outline-none', 'origin-(--radix-dropdown-menu-content-transform-origin)', 'data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=open]:duration-(--rb-duration-d3) data-[state=open]:ease-enter', 'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=closed]:duration-(--rb-duration-d2) data-[state=closed]:ease-exit', ], { variants: { size: { medium: 'w-[240px]', small: 'w-[200px]', responsive: 'w-[240px] lg:w-[200px]', }, matchReferenceWidth: { true: 'w-(--radix-dropdown-menu-trigger-width) lg:w-(--radix-dropdown-menu-trigger-width)', false: '', }, }, },);export interface MenuContentProps extends React.ComponentProps<typeof MenuPrimitive.Content> { /** 포털을 렌더링할 컨테이너예요. */ positionerContainer?: React.ComponentProps<typeof MenuPrimitive.Portal>['container'];}function MenuContent({ className, children, positionerContainer, ...props }: MenuContentProps) { const { size, placement, matchReferenceWidth } = React.useContext(MenuContext); const { side, align } = parsePlacement(placement); return ( <MenuPrimitive.Portal container={positionerContainer}> <MenuPrimitive.Content data-slot="menu-content" side={side} align={align} // gutter: $dimension.x2, overflowPadding: $dimension.x2 sideOffset={8} collisionPadding={8} className={cn(menuContentVariants({ size, matchReferenceWidth }), className)} {...props} > <div data-slot="menu-scroll-area" className="box-border flex max-h-[min(480px,var(--radix-dropdown-menu-content-available-height,480px))] flex-col gap-x2 overflow-y-auto py-x2" > {children} </div> </MenuPrimitive.Content> </MenuPrimitive.Portal> );}export interface MenuGroupProps extends React.ComponentProps<typeof MenuPrimitive.Group> {}/** 연관된 항목을 묶어요. 그룹 사이에는 Divider 가 자동으로 표시돼요. */function MenuGroup({ className, ...props }: MenuGroupProps) { return ( <MenuPrimitive.Group data-slot="menu-group" className={cn( 'flex flex-col', '[[data-slot=menu-group]+&]:before:mx-x4 [[data-slot=menu-group]+&]:before:mb-x2 [[data-slot=menu-group]+&]:before:block', '[[data-slot=menu-group]+&]:before:h-px [[data-slot=menu-group]+&]:before:shrink-0 [[data-slot=menu-group]+&]:before:bg-stroke-neutral-muted', className, )} {...props} /> );}const menuGroupLabelVariants = cva('select-none text-fg-neutral-subtle', { variants: { size: { medium: 'px-x4 py-x2_5 text-t4 font-medium', small: 'px-x4 py-x2 text-t3 font-regular', responsive: 'px-x4 py-x2_5 text-t4 font-medium lg:py-x2 lg:text-t3 lg:font-regular', }, },});export interface MenuGroupLabelProps extends React.ComponentProps<typeof MenuPrimitive.Label> {}function MenuGroupLabel({ className, ...props }: MenuGroupLabelProps) { const { size } = React.useContext(MenuContext); return ( <MenuPrimitive.Label data-slot="menu-group-label" className={cn(menuGroupLabelVariants({ size }), className)} {...props} /> );}const menuItemVariants = cva( [ 'group/menu-item relative isolate flex cursor-default select-none items-center text-start outline-none', 'scroll-my-x2', // pressed 배경: 좌우 x2 안쪽으로 줄어들며 r3 모서리를 가져요. 'before:absolute before:inset-0 before:-z-10', 'before:transition-[background-color,inset-inline,border-radius] before:duration-(--rb-duration-color-transition) before:ease-easing', 'data-[highlighted]:not-data-[disabled]:before:inset-x-x2 data-[highlighted]:not-data-[disabled]:before:rounded-r3 data-[highlighted]:not-data-[disabled]:before:bg-bg-transparent-pressed', 'active:not-data-[disabled]:before:inset-x-x2 active:not-data-[disabled]:before:rounded-r3 active:not-data-[disabled]:before:bg-bg-transparent-pressed', // 포커스 링: pressed 배경과 같은 영역 안쪽에 그려요. 'after:absolute after:inset-y-0 after:inset-x-x2 after:rounded-r3', 'after:outline-2 after:-outline-offset-2 after:outline-transparent after:transition-[outline-color] after:duration-(--rb-duration-color-transition)', 'focus-visible:after:outline-stroke-focus-ring', // scaleScope: content — 배경은 그대로, 내용만 작아져요. '[&>[data-slot=menu-item-content]]:transition-[scale] [&>[data-slot=menu-item-content]]:duration-(--rb-duration-pressed-scale) [&>[data-slot=menu-item-content]]:ease-pressed-scale', 'active:not-data-[disabled]:[&>[data-slot=menu-item-content]]:scale-[0.98]', 'data-[disabled]:cursor-not-allowed', ], { variants: { size: { medium: 'px-x4 py-x3 [&_[data-slot=menu-item-content]]:gap-x3 [&_[data-slot=menu-item-prefix-icon]_svg]:size-[22px] [&_[data-slot=menu-item-suffix-icon]_svg]:size-[18px] [&_[data-slot=menu-item-label]]:text-t5 [&_[data-slot=menu-item-description]]:text-t3', small: 'px-x4 py-x2_5 [&_[data-slot=menu-item-content]]:gap-x2 [&_[data-slot=menu-item-prefix-icon]_svg]:size-[18px] [&_[data-slot=menu-item-suffix-icon]_svg]:size-x4 [&_[data-slot=menu-item-label]]:text-t4 [&_[data-slot=menu-item-description]]:text-t2', responsive: [ 'px-x4 py-x3 [&_[data-slot=menu-item-content]]:gap-x3 [&_[data-slot=menu-item-prefix-icon]_svg]:size-[22px] [&_[data-slot=menu-item-suffix-icon]_svg]:size-[18px] [&_[data-slot=menu-item-label]]:text-t5 [&_[data-slot=menu-item-description]]:text-t3', 'lg:py-x2_5 lg:[&_[data-slot=menu-item-content]]:gap-x2 lg:[&_[data-slot=menu-item-prefix-icon]_svg]:size-[18px] lg:[&_[data-slot=menu-item-suffix-icon]_svg]:size-x4 lg:[&_[data-slot=menu-item-label]]:text-t4 lg:[&_[data-slot=menu-item-description]]:text-t2', ], }, tone: { neutral: 'text-fg-neutral', critical: 'text-fg-critical', }, }, defaultVariants: { size: 'medium', tone: 'neutral', }, },);export interface MenuItemProps extends Omit<React.ComponentProps<typeof MenuPrimitive.Item>, 'children' | 'asChild'> { /** 항목 앞 아이콘이에요. 의미나 카테고리를 나타낼 때 써요. */ prefixIcon?: React.ReactNode; /** 항목 이름이에요. */ label: React.ReactNode; /** Label 을 보완하는 한 줄 설명이에요. */ description?: React.ReactNode; /** 항목 뒤 아이콘이에요. 서브메뉴, 외부 링크 등 방향성을 나타낼 때 써요. */ suffixIcon?: React.ReactNode; /** 삭제처럼 되돌릴 수 없는 액션에는 `critical` 을 써요. */ tone?: 'neutral' | 'critical';}function MenuItem({ className, prefixIcon, label, description, suffixIcon, tone, ...props }: MenuItemProps) { const { size } = React.useContext(MenuContext); return ( <MenuPrimitive.Item data-slot="menu-item" data-tone={tone ?? 'neutral'} className={cn(menuItemVariants({ size, tone }), 'data-[disabled]:text-fg-disabled', className)} {...props} > <span data-slot="menu-item-content" className="flex min-w-0 flex-1 items-center"> {prefixIcon && ( <span data-slot="menu-item-prefix-icon" className="inline-flex shrink-0 [&_svg]:shrink-0"> {prefixIcon} </span> )} <span data-slot="menu-item-body" className="flex min-w-0 grow flex-col gap-x0_5"> <span data-slot="menu-item-label" className="font-regular"> {label} </span> {description && ( <span data-slot="menu-item-description" className="font-regular text-fg-neutral-subtle group-data-[disabled]/menu-item:text-fg-disabled" > {description} </span> )} </span> {suffixIcon && ( <span data-slot="menu-item-suffix-icon" className="inline-flex shrink-0 [&_svg]:shrink-0"> {suffixIcon} </span> )} </span> </MenuPrimitive.Item> );}export { MenuRoot, MenuTrigger, MenuContent, MenuGroup, MenuGroupLabel, MenuItem, menuItemVariants };