/** * @title Split Button * @description 기본 액션 버튼과 관련 액션을 모은 드롭다운 버튼을 하나로 붙인 버튼이에요. */import * as React from 'react';import { DropdownMenu as DropdownMenuPrimitive } from 'radix-ui';import { ChevronDown } from 'lucide-react';import { cn } from '@/lib/utils';import { IconButton, SplitButtonContext, type IconButtonProps } from '@/components/ui/button';export type SplitButtonAppearance = 'default' | 'primary';export type SplitButtonSpacing = 'default' | 'compact';export interface SplitButtonProps { /** * 자식은 두 개만 넣어요. 첫 번째는 기본 액션(Button), 두 번째는 보조 액션(드롭다운 트리거 IconButton 등)이에요. * 두 자식 안의 Button·IconButton 은 SplitButton 의 appearance·spacing·isDisabled 를 따라가요. */ children: React.ReactNode; /** 안쪽 버튼 스타일. 자식 버튼의 appearance 보다 우선해요. */ appearance?: SplitButtonAppearance; /** 안쪽 버튼 여백 */ spacing?: SplitButtonSpacing; /** 두 버튼을 모두 비활성으로 만들어요. */ isDisabled?: boolean; /** 자동화 테스트용 `data-testid` */ testId?: string; className?: string;}/** * split-button/divider 해독 값 * - default: border 색, 높이 = 버튼 높이 - 테두리 2개 * - primary: border.inverse, opacity .64, 버튼 높이 * - disabled: border.disabled */function Divider({ appearance, spacing, isDisabled }: { appearance: SplitButtonAppearance; spacing: SplitButtonSpacing; isDisabled: boolean }) { const isDefaultDivider = appearance === 'default' && !isDisabled; return ( <div data-slot="split-button-divider" aria-hidden className={cn( 'relative z-[1] -mx-px w-(--ds-border-width)', isDefaultDivider && (spacing === 'compact' ? 'h-[calc(1.5rem-var(--ds-border-width)*2)]' : 'h-[calc(2rem-var(--ds-border-width)*2)]'), !isDefaultDivider && (spacing === 'compact' ? 'h-6' : 'h-8'), isDisabled && 'cursor-not-allowed bg-ds-border-disabled', !isDisabled && appearance === 'default' && 'bg-ds-border', !isDisabled && appearance === 'primary' && 'bg-ds-border-inverse opacity-64', )} /> );}/** 두 자식을 받아 기본 액션 | 구분선 | 보조 액션 으로 배치해요. */function SplitButton({ children, appearance = 'default', spacing = 'default', isDisabled = false, testId, className,}: SplitButtonProps) { const [primaryAction, secondaryAction] = React.Children.toArray(children); const context = React.useMemo(() => ({ appearance, spacing, isDisabled }), [appearance, spacing, isDisabled]); return ( <div data-slot="split-button" data-appearance={appearance} data-testid={testId} className={cn( 'relative inline-flex items-center whitespace-nowrap', // default 는 바깥 컨테이너가 1px 테두리를 그린다 (안쪽 버튼은 subtle) appearance === 'default' && 'rounded-ds-medium outline-1 -outline-offset-1 outline-ds-border outline-solid', appearance === 'default' && isDisabled && 'outline-ds-border-disabled', className, )} > <SplitButtonContext.Provider value={context}> <div data-slot="split-button-primary" className="flex [&_a]:rounded-e-none [&_button]:rounded-e-none"> {primaryAction} </div> <Divider appearance={appearance} spacing={spacing} isDisabled={isDisabled} /> <div data-slot="split-button-secondary" className="flex [&_a]:rounded-s-none [&_button]:rounded-s-none"> {secondaryAction} </div> </SplitButtonContext.Provider> </div> );}/* ------------------------------------------------------------------------------------------------- * 보조 액션용 드롭다운 — radix DropdownMenu 에 ADS popup·menu 스타일을 입힌 최소 구성 * (popup: radius.large, surface.overlay, shadow.overlay / menu item: 최소 높이 40px, body 글꼴) * -----------------------------------------------------------------------------------------------*/const DropdownOpenContext = React.createContext(false);/** radix DropdownMenu.Root. 열림 상태를 트리거에 알려 선택 스타일을 입혀요. */function SplitButtonDropdown({ open: openProp, defaultOpen = false, onOpenChange, ...props}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) { const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen); const open = openProp ?? uncontrolledOpen; const handleOpenChange = (next: boolean) => { if (openProp === undefined) setUncontrolledOpen(next); onOpenChange?.(next); }; return ( <DropdownOpenContext.Provider value={open}> <DropdownMenuPrimitive.Root data-slot="split-button-dropdown" open={open} onOpenChange={handleOpenChange} {...props} /> </DropdownOpenContext.Provider> );}export interface SplitButtonDropdownTriggerProps extends Omit<IconButtonProps, 'icon' | 'isSelected'> { /** 기본은 아래쪽 화살표 */ icon?: IconButtonProps['icon'];}/** * 보조 액션 트리거. 열려 있는 동안 선택 상태로 보여요. label 은 "○○ 관련 액션 더보기" 처럼 써요. * primary SplitButton 에서는 열려 있어도 브랜드 색(brand.subtlest)을 유지하고, 기본 화살표는 12px 이에요. */function SplitButtonDropdownTrigger({ icon, ...props }: SplitButtonDropdownTriggerProps) { const open = React.useContext(DropdownOpenContext); return ( <DropdownMenuPrimitive.Trigger asChild> <IconButton icon={icon ?? <ChevronDown />} isSelected={open} {...props} /> </DropdownMenuPrimitive.Trigger> );}function SplitButtonDropdownContent({ className, align = 'end', sideOffset = 8, ...props}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) { return ( <DropdownMenuPrimitive.Portal> <DropdownMenuPrimitive.Content data-slot="split-button-dropdown-content" align={align} sideOffset={sideOffset} className={cn( 'z-[400] max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-40 overflow-y-auto rounded-ds-large bg-ds-surface-overlay py-ds-050 text-ds-text shadow-ds-overlay outline-none', // platform-dst-motion-uplift-popup: 배치 방향에서 8px 슬라이드 + 페이드 인(150ms), 4px 슬라이드 + 페이드 아웃(100ms) 'data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:duration-150 data-[state=open]:ease-[cubic-bezier(0.4,1,0.6,1)]', 'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:duration-100 data-[state=closed]:ease-[cubic-bezier(0.6,0,0.8,0.6)]', 'data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2', 'data-[side=bottom]:slide-out-to-top-1 data-[side=top]:slide-out-to-bottom-1 data-[side=left]:slide-out-to-right-1 data-[side=right]:slide-out-to-left-1', 'motion-reduce:animate-none', className, )} {...props} /> </DropdownMenuPrimitive.Portal> );}export interface SplitButtonDropdownItemProps extends React.ComponentProps<typeof DropdownMenuPrimitive.Item> { /** 제목 아래 보조 설명 */ description?: React.ReactNode; elemBefore?: React.ReactNode; elemAfter?: React.ReactNode;}function SplitButtonDropdownItem({ className, children, description, elemBefore, elemAfter, ...props }: SplitButtonDropdownItemProps) { return ( <DropdownMenuPrimitive.Item data-slot="split-button-dropdown-item" className={cn( 'relative flex min-h-10 w-full cursor-pointer items-center gap-ds-100 px-ds-200 py-ds-100 text-start ds-font-body text-ds-text outline-none select-none', 'bg-ds-background-neutral-subtle hover:bg-ds-background-neutral-subtle-hovered active:bg-ds-background-neutral-subtle-pressed data-highlighted:bg-ds-background-neutral-subtle-hovered', 'focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ds-border-focused', 'data-disabled:cursor-not-allowed data-disabled:bg-transparent data-disabled:text-ds-text-disabled', '[&_svg]:size-4 [&_svg]:shrink-0', className, )} {...props} > {elemBefore && <span className="flex min-h-6 min-w-6 shrink-0 items-center justify-center">{elemBefore}</span>} <span className="flex min-w-0 grow flex-col justify-center overflow-hidden"> <span className="truncate">{children}</span> {description && <span className="truncate ds-font-body-small text-ds-text-subtlest">{description}</span>} </span> {elemAfter && <span className="flex min-h-6 min-w-6 shrink-0 items-center justify-center">{elemAfter}</span>} </DropdownMenuPrimitive.Item> );}export { SplitButton, SplitButtonDropdown, SplitButtonDropdownTrigger, SplitButtonDropdownContent, SplitButtonDropdownItem,};