/** * @title Button * @description 사용자가 액션을 실행하거나 선택하도록 돕는 버튼이에요. 링크 버튼·아이콘 버튼 변형을 함께 제공해요. */import * as React from 'react';import { Slot, Tooltip as TooltipPrimitive } from 'radix-ui';import { cva } from 'class-variance-authority';import { cn } from '@/lib/utils';/* ------------------------------------------------------------------------------------------------- * 타입 (@atlaskit/button/new 의 variants/types 와 같은 이름) * -----------------------------------------------------------------------------------------------*/export type ButtonAppearance = 'default' | 'primary' | 'subtle' | 'warning' | 'danger' | 'discovery';export type LinkButtonAppearance = ButtonAppearance;export type IconButtonAppearance = 'default' | 'primary' | 'discovery' | 'subtle';export type ButtonSpacing = 'default' | 'compact';export type IconButtonSpacing = ButtonSpacing;/** 아이콘 슬롯: 엘리먼트(`<Plus />`) 또는 컴포넌트(`Plus`) 둘 다 받아요. */export type IconProp = React.ReactNode | React.ComponentType<{ className?: string; 'aria-hidden'?: boolean }>;/* ------------------------------------------------------------------------------------------------- * Split button 컨텍스트 — SplitButton 안의 버튼은 appearance·spacing·isDisabled 를 여기서 받는다. * (split-button.tsx 가 Provider 를 쓴다) * -----------------------------------------------------------------------------------------------*/export type SplitButtonContextValue = { appearance: 'default' | 'primary'; spacing: ButtonSpacing; isDisabled: boolean;};const SplitButtonContext = React.createContext<SplitButtonContextValue | undefined>(undefined);/* ------------------------------------------------------------------------------------------------- * 스타일 — variants/shared/button-base 해독 값 * height 2rem / compact 1.5rem, padding-block space.075 / compact space.025, padding-inline space.150, * column-gap space.050 (아이콘 있으면 space.075), radius.medium / compact radius.small, * font.body + font.weight.medium, ::after 로 1px 테두리, hover/pressed 는 상호작용 가능할 때만. * -----------------------------------------------------------------------------------------------*/const buttonVariants = cva( [ 'relative box-border inline-flex h-8 w-auto max-w-full shrink-0 cursor-pointer items-baseline justify-center gap-ds-050 align-middle', 'rounded-ds-medium border-0 px-ds-150 py-ds-075 text-center ds-font-body font-ds-medium whitespace-nowrap', 'no-underline hover:no-underline focus:no-underline active:no-underline', 'transition-[background] duration-100 ease-out', 'not-aria-disabled:transition-[background-color,border-color] not-aria-disabled:duration-150 not-aria-disabled:ease-[cubic-bezier(0.4,1,0.6,1)]', // 테두리는 ::after 로 그린다 (default·selected 만 색이 보인다) 'after:pointer-events-none after:absolute after:inset-0 after:rounded-[inherit] after:border-solid after:border-(length:--ds-border-width) after:border-transparent', // ADS focus ring 'outline-2 outline-offset-2 outline-transparent focus-visible:outline-ds-border-focused', '[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', // platform_dst_button_chevron_sizing: Chevron 아이콘은 small(12px), 나머지는 medium(16px) '[&_svg[class*=lucide-chevron]]:size-3', ], { variants: { appearance: { default: [ 'bg-ds-background-neutral-subtle text-ds-text-subtle visited:text-ds-text-subtle after:border-ds-border', 'not-aria-disabled:hover:bg-ds-background-neutral-subtle-hovered not-aria-disabled:active:bg-ds-background-neutral-subtle-pressed', ], primary: [ 'bg-ds-background-brand-bold text-ds-text-inverse visited:text-ds-text-inverse', 'not-aria-disabled:hover:bg-ds-background-brand-bold-hovered not-aria-disabled:active:bg-ds-background-brand-bold-pressed', ], subtle: [ 'bg-ds-background-neutral-subtle text-ds-text-subtle visited:text-ds-text-subtle', 'not-aria-disabled:hover:bg-ds-background-neutral-subtle-hovered not-aria-disabled:active:bg-ds-background-neutral-subtle-pressed', ], warning: [ 'bg-ds-background-warning-bold text-ds-text-warning-inverse visited:text-ds-text-warning-inverse', 'not-aria-disabled:hover:bg-ds-background-warning-bold-hovered not-aria-disabled:active:bg-ds-background-warning-bold-pressed', ], danger: [ 'bg-ds-background-danger-bold text-ds-text-inverse visited:text-ds-text-inverse', 'not-aria-disabled:hover:bg-ds-background-danger-bold-hovered not-aria-disabled:active:bg-ds-background-danger-bold-pressed', ], discovery: [ 'bg-ds-background-discovery-bold text-ds-text-inverse visited:text-ds-text-inverse', 'not-aria-disabled:hover:bg-ds-background-discovery-bold-hovered not-aria-disabled:active:bg-ds-background-discovery-bold-pressed', ], }, spacing: { default: '', compact: 'h-6 rounded-ds-small py-ds-025', }, selected: { true: [ 'bg-ds-background-selected text-ds-text-selected visited:text-ds-text-selected after:border-ds-border-selected', 'not-aria-disabled:hover:bg-ds-background-selected-hovered not-aria-disabled:active:bg-ds-background-selected-pressed', ], false: '', }, fullWidth: { true: 'w-full', false: 'w-auto', }, }, compoundVariants: [ // warning·danger·discovery 는 선택됐을 때 hover/pressed 색이 바뀌지 않는다 { selected: true, appearance: ['warning', 'danger', 'discovery'], className: 'not-aria-disabled:hover:bg-ds-background-selected not-aria-disabled:active:bg-ds-background-selected', }, ], defaultVariants: { appearance: 'default', spacing: 'default', selected: false, fullWidth: false, }, },);/** * FY27 Finesse (platform-dst-tokens-finesse) brandSelectedSplitButtonStyles: * primary SplitButton 안의 primary 버튼이 선택(열림)되면 중립 selected 대신 브랜드 subtlest 색을 유지한다. */const brandSelectedSplitClass = cn( 'bg-ds-background-brand-subtlest text-ds-text-brand visited:text-ds-text-brand focus:text-ds-text-brand after:border-ds-border-brand', 'hover:text-ds-text-brand active:text-ds-text-brand', 'not-aria-disabled:hover:bg-ds-background-brand-subtlest-hovered not-aria-disabled:active:bg-ds-background-brand-subtlest-pressed',);/** 비활성 (<button>): 글자는 text.disabled, default·subtle 은 배경 투명, 나머지는 background.disabled */function buttonDisabledClass(appearance: ButtonAppearance) { return cn( 'cursor-not-allowed text-ds-text-disabled hover:text-ds-text-disabled active:text-ds-text-disabled', appearance === 'default' || appearance === 'subtle' ? 'bg-transparent hover:bg-transparent active:bg-transparent' : 'bg-ds-background-disabled hover:bg-ds-background-disabled active:bg-ds-background-disabled', appearance === 'default' ? 'after:border-ds-border-disabled' : 'after:border-transparent', );}/** 비활성 (링크 버튼): appearance 와 상관없이 background.disabled + 테두리 없음 */const linkDisabledClass = 'cursor-not-allowed bg-ds-background-disabled text-ds-text-disabled visited:text-ds-text-disabled hover:bg-ds-background-disabled hover:text-ds-text-disabled active:bg-ds-background-disabled active:text-ds-text-disabled after:border-transparent';/* ------------------------------------------------------------------------------------------------- * 내부 조각 — Content(텍스트·아이콘 슬롯), 로딩 스피너, 이벤트 차단 * -----------------------------------------------------------------------------------------------*/function renderIcon(icon: IconProp) { if (icon == null || typeof icon === 'boolean') return null; if (React.isValidElement(icon)) return icon; // 컴포넌트(함수·forwardRef·memo)면 렌더한다 if (typeof icon === 'function' || (typeof icon === 'object' && !Array.isArray(icon) && '$$typeof' in icon)) { const Icon = icon as React.ComponentType<{ 'aria-hidden'?: boolean }>; return <Icon aria-hidden />; } return icon as React.ReactNode;}/** variants/shared/content: 텍스트는 말줄임, 아이콘은 줄 높이 0 으로 가운데 정렬. 로딩 중에는 투명해진다. */function Content({ type = 'text', isLoading, children }: { type?: 'text' | 'icon'; isLoading?: boolean; children: React.ReactNode }) { return ( <span data-slot={type === 'icon' ? 'button-icon' : 'button-label'} className={cn( 'transition-opacity duration-300', type === 'text' && 'min-w-0 shrink grow overflow-hidden text-ellipsis whitespace-nowrap', type === 'icon' && 'flex shrink-0 grow-0 self-center text-[length:0] leading-[0] select-none', isLoading && 'opacity-0', )} > {children} </span> );}/** @atlaskit/spinner 대체 — medium 16px, small(compact) 12px, 색은 currentColor */function ButtonSpinner({ spacing }: { spacing: ButtonSpacing }) { return ( <svg viewBox="0 0 16 16" className={cn('animate-spin [animation-duration:0.86s]', spacing === 'compact' ? 'size-3!' : 'size-4!')} fill="none" aria-hidden > <circle cx="8" cy="8" r="6.5" stroke="currentColor" strokeWidth="1.5" strokeOpacity="0.25" /> <path d="M14.5 8A6.5 6.5 0 0 0 8 1.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" /> </svg> );}const LOADING_LABEL = ', 로딩 중';const NEW_WINDOW_LABEL = '(새 창에서 열림)';/** variants/shared/block-events: 비활성·로딩 중에는 포인터·키 이벤트를 캡처 단계에서 막는다 (Tab 이동은 허용). */function blockEvents(isBlocked: boolean) { if (!isBlocked) return {}; const stop = (e: React.SyntheticEvent) => { e.preventDefault(); e.stopPropagation(); }; return { onClickCapture: stop, onMouseDownCapture: stop, onMouseUpCapture: stop, onPointerDownCapture: stop, onPointerUpCapture: stop, onTouchStartCapture: stop, onTouchEndCapture: stop, onKeyDownCapture: (e: React.KeyboardEvent) => { if (e.key !== 'Tab') stop(e); }, onKeyUpCapture: (e: React.KeyboardEvent) => { if (e.key !== 'Tab') stop(e); }, };}/** 새 창 링크일 때 스크린 리더에만 읽히는 안내 (Anchor primitive 와 같은 규칙) */function useNewWindowLabel({ target, ariaLabel, ariaLabelledBy, newWindowLabel = NEW_WINDOW_LABEL,}: { target?: string; ariaLabel?: string; ariaLabelledBy?: string; newWindowLabel?: string;}) { const id = React.useId(); const isNewWindow = target === '_blank'; return { 'aria-label': ariaLabel && isNewWindow && !ariaLabelledBy ? `${ariaLabel} , ${newWindowLabel}` : ariaLabel, 'aria-labelledby': ariaLabelledBy && isNewWindow ? `${ariaLabelledBy} ${id}` : ariaLabelledBy, hidden: isNewWindow && (!ariaLabel || ariaLabelledBy) ? ( <span id={id} className="sr-only"> {`, ${newWindowLabel}`} </span> ) : null, };}/* ------------------------------------------------------------------------------------------------- * Button * -----------------------------------------------------------------------------------------------*/export interface ButtonProps extends Omit<React.ComponentProps<'button'>, 'disabled'> { /** 버튼 스타일. 화면의 핵심 액션 하나에만 `primary` 를 써요. */ appearance?: ButtonAppearance; /** `compact` 는 24px 높이로 표·툴바에 써요. */ spacing?: ButtonSpacing; /** 라벨 앞 아이콘 */ iconBefore?: IconProp; /** 라벨 뒤 아이콘 */ iconAfter?: IconProp; /** 로딩 중에는 스피너를 보여주고 클릭을 막아요. */ isLoading?: boolean; isDisabled?: boolean; isSelected?: boolean; /** 부모 너비를 꽉 채워요. */ shouldFitContainer?: boolean; /** 자동화 테스트용 `data-testid` */ testId?: string; /** 자식 요소(링크 등)에 버튼 스타일을 입혀요. */ asChild?: boolean;}/** 내부용: Button·IconButton 이 공유하는 상태 계산 (split button 컨텍스트 반영) */function useButtonState({ appearance: propAppearance, spacing: propSpacing, isDisabled: propIsDisabled, isSelected: propIsSelected, isLoading,}: { appearance?: ButtonAppearance; spacing?: ButtonSpacing; isDisabled?: boolean; isSelected?: boolean; isLoading?: boolean;}) { const split = React.useContext(SplitButtonContext); // split button 의 default 는 바깥 컨테이너가 테두리를 그리고, 안쪽 버튼은 subtle 이 된다 const appearance: ButtonAppearance = split?.appearance === 'default' ? 'subtle' : (propAppearance ?? split?.appearance ?? 'default'); const spacing: ButtonSpacing = split?.spacing ?? propSpacing ?? 'default'; const isDisabled = Boolean(split?.isDisabled || propIsDisabled); const isSelected = Boolean(propIsSelected) && !isDisabled; const inactive = isDisabled || Boolean(isLoading); // 열린 드롭다운은 primary 액션의 일부라서 브랜드 색을 유지한다 (Finesse) const isBrandSelectedSplit = isSelected && appearance === 'primary' && split?.appearance === 'primary'; return { appearance, spacing, isDisabled, isSelected, inactive, isSplit: Boolean(split), isBrandSelectedSplit };}/** 내부 구현. `iconOnly` 면 children 을 텍스트 슬롯으로 감싸지 않는다 (IconButton 용). */function ButtonImpl({ iconOnly = false, className, appearance: appearanceProp, spacing: spacingProp, iconBefore, iconAfter, isLoading = false, isDisabled: isDisabledProp = false, isSelected: isSelectedProp = false, shouldFitContainer = false, testId, asChild = false, type = 'button', children, onClick, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, ...props}: ButtonProps & { iconOnly?: boolean }) { const { appearance, spacing, isDisabled, isSelected, inactive, isSplit, isBrandSelectedSplit } = useButtonState({ appearance: appearanceProp, spacing: spacingProp, isDisabled: isDisabledProp, isSelected: isSelectedProp, isLoading, }); const loadingLabelId = React.useId(); const Comp = asChild ? Slot.Root : 'button'; const hasIconBefore = Boolean(iconBefore); const hasIconAfter = Boolean(iconAfter); return ( <Comp data-slot="button" data-appearance={appearance} data-spacing={spacing} data-testid={testId} type={asChild ? undefined : type} disabled={!asChild ? isDisabled : undefined} aria-disabled={inactive || undefined} // 메뉴 트리거(aria-haspopup)일 때는 aria-expanded 가 상태를 알리므로 aria-pressed 를 생략 aria-pressed={(isSelected && props['aria-haspopup'] === undefined) || undefined} aria-busy={isLoading || undefined} aria-live="polite" aria-label={isLoading && ariaLabel && !ariaLabelledBy ? `${ariaLabel} ${LOADING_LABEL}` : ariaLabel} aria-labelledby={isLoading && ariaLabelledBy ? `${ariaLabelledBy} ${loadingLabelId}` : ariaLabelledBy} className={cn( buttonVariants({ appearance, spacing, selected: isSelected, fullWidth: shouldFitContainer }), (hasIconBefore || hasIconAfter) && 'gap-ds-075', isBrandSelectedSplit && brandSelectedSplitClass, isDisabled && buttonDisabledClass(appearance), isLoading && 'cursor-progress', isSplit && 'focus-visible:z-[100]', isSplit && isSelected && 'z-[2]', className, )} onClick={onClick} {...blockEvents(inactive)} {...props} > {asChild ? ( children ) : ( <> {hasIconBefore && ( <Content type="icon" isLoading={isLoading}> {renderIcon(iconBefore)} </Content> )} {iconOnly ? children : children != null && children !== false && <Content isLoading={isLoading}>{children}</Content>} {hasIconAfter && ( <Content type="icon" isLoading={isLoading}> {renderIcon(iconAfter)} </Content> )} {isLoading && ( <span className="absolute inset-0 flex items-center justify-center overflow-hidden"> <ButtonSpinner spacing={spacing} /> </span> )} {isLoading && (ariaLabelledBy || !ariaLabel) && ( <span id={loadingLabelId} className="sr-only"> {LOADING_LABEL} </span> )} </> )} </Comp> );}function Button(props: ButtonProps) { return <ButtonImpl {...props} />;}/* ------------------------------------------------------------------------------------------------- * Tooltip (IconButton 용) — @atlaskit/tooltip 스타일: neutral.bold 배경, inverse 글자, body.small * -----------------------------------------------------------------------------------------------*/export type IconButtonTooltipProps = { /** 툴팁 내용. 생략하면 label 을 보여줘요. */ content?: React.ReactNode; position?: 'top' | 'bottom' | 'left' | 'right'; /** 표시까지 지연(ms). 기본 300 */ delay?: number; testId?: string;};function WithTooltip({ enabled, label, tooltip, children,}: { enabled: boolean; label: React.ReactNode; tooltip?: IconButtonTooltipProps; children: React.ReactElement;}) { if (!enabled) return children; return ( <TooltipPrimitive.Provider delayDuration={tooltip?.delay ?? 300}> <TooltipPrimitive.Root> <TooltipPrimitive.Trigger asChild>{children}</TooltipPrimitive.Trigger> <TooltipPrimitive.Portal> <TooltipPrimitive.Content side={tooltip?.position ?? 'bottom'} sideOffset={8} data-testid={tooltip?.testId} className="z-[800] max-w-[240px] rounded-ds-small bg-ds-background-neutral-bold px-ds-075 py-ds-025 ds-font-body-small text-ds-text-inverse break-words animate-in fade-in-0 data-[state=closed]:animate-out data-[state=closed]:fade-out-0" > {tooltip?.content ?? label} </TooltipPrimitive.Content> </TooltipPrimitive.Portal> </TooltipPrimitive.Root> </TooltipPrimitive.Provider> );}/* ------------------------------------------------------------------------------------------------- * IconButton — variants/icon/button * -----------------------------------------------------------------------------------------------*/export interface IconButtonProps extends Omit<ButtonProps, 'iconBefore' | 'iconAfter' | 'children' | 'appearance' | 'shouldFitContainer' | 'aria-label'> { appearance?: IconButtonAppearance; /** 버튼 안의 아이콘 */ icon: IconProp; /** 스크린 리더용 이름 (필수). 화면에는 보이지 않는 텍스트로 들어가요. */ label: React.ReactNode; /** 기본은 둥근 사각형, `circle` 은 원형 */ shape?: 'default' | 'circle'; /** `false` 로 두면 label 을 툴팁으로 보여줘요. 기본 `true` (툴팁 없음) */ isTooltipDisabled?: boolean; /** 툴팁 옵션 */ tooltip?: IconButtonTooltipProps;}/** 아이콘만 있는 버튼. label 은 화면에 보이지 않는 텍스트로 들어가요. */function IconButton({ icon, label, shape = 'default', spacing, isTooltipDisabled = true, tooltip, isLoading = false, className, ...props}: IconButtonProps) { const split = React.useContext(SplitButtonContext); const resolvedSpacing = split?.spacing ?? spacing ?? 'default'; return ( <WithTooltip enabled={!isTooltipDisabled} label={label} tooltip={tooltip}> <ButtonImpl iconOnly data-slot="icon-button" spacing={spacing} isLoading={isLoading} className={cn( 'size-8 px-ds-0', resolvedSpacing === 'compact' && 'size-6', shape === 'circle' && !split && 'rounded-ds-full', className, )} {...props} > <span className={cn( 'flex shrink-0 grow-0 self-center text-[length:0] leading-[0] transition-opacity duration-300 select-none', isLoading && 'opacity-0', )} > {renderIcon(icon)} <span className="sr-only">{label}</span> </span> </ButtonImpl> </WithTooltip> );}/* ------------------------------------------------------------------------------------------------- * LinkButton — variants/default/link (버튼처럼 보이는 링크) * -----------------------------------------------------------------------------------------------*/export interface LinkButtonProps extends Omit<React.ComponentProps<'a'>, 'href'> { /** 이동할 URL (필수) */ href: string; appearance?: LinkButtonAppearance; spacing?: ButtonSpacing; iconBefore?: IconProp; iconAfter?: IconProp; /** 비활성 시 href 를 지우고 `role="link"` + `aria-disabled` 로 접근 가능하게 막아요. */ isDisabled?: boolean; isSelected?: boolean; shouldFitContainer?: boolean; /** `target="_blank"` 일 때 스크린 리더에 읽히는 안내. 기본 "(새 창에서 열림)" */ newWindowLabel?: string; testId?: string;}function LinkButtonImpl({ iconOnly = false, href, appearance = 'default', spacing = 'default', iconBefore, iconAfter, isDisabled = false, isSelected = false, shouldFitContainer = false, newWindowLabel, testId, target, className, children, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, ...props}: LinkButtonProps & { iconOnly?: boolean }) { const a11y = useNewWindowLabel({ target, ariaLabel, ariaLabelledBy, newWindowLabel }); const selected = isSelected && !isDisabled; return ( <a data-slot="link-button" data-appearance={appearance} data-testid={testId} href={isDisabled ? undefined : href} role={isDisabled ? 'link' : undefined} aria-disabled={isDisabled || undefined} aria-label={a11y['aria-label']} aria-labelledby={a11y['aria-labelledby']} target={target} className={cn( buttonVariants({ appearance, spacing, selected, fullWidth: shouldFitContainer }), (iconBefore || iconAfter) && 'gap-ds-075', isDisabled && linkDisabledClass, className, )} {...props} > {iconBefore && <Content type="icon">{renderIcon(iconBefore)}</Content>} {iconOnly ? children : children != null && children !== false && <Content>{children}</Content>} {iconAfter && <Content type="icon">{renderIcon(iconAfter)}</Content>} {a11y.hidden} </a> );}function LinkButton(props: LinkButtonProps) { return <LinkButtonImpl {...props} />;}/* ------------------------------------------------------------------------------------------------- * LinkIconButton — variants/icon/link * -----------------------------------------------------------------------------------------------*/export interface LinkIconButtonProps extends Omit<LinkButtonProps, 'iconBefore' | 'iconAfter' | 'children' | 'appearance' | 'shouldFitContainer' | 'aria-label'> { appearance?: IconButtonAppearance; icon: IconProp; /** 스크린 리더용 이름 (필수) */ label: React.ReactNode; shape?: 'default' | 'circle'; isTooltipDisabled?: boolean; tooltip?: IconButtonTooltipProps;}function LinkIconButton({ icon, label, shape = 'default', spacing = 'default', isTooltipDisabled = true, tooltip, className, ...props}: LinkIconButtonProps) { return ( <WithTooltip enabled={!isTooltipDisabled} label={label} tooltip={tooltip}> <LinkButtonImpl iconOnly data-slot="link-icon-button" spacing={spacing} className={cn('size-8 px-ds-0', spacing === 'compact' && 'size-6', shape === 'circle' && 'rounded-ds-full', className)} {...props} > <span className="flex shrink-0 grow-0 self-center text-[length:0] leading-[0] select-none"> {renderIcon(icon)} <span className="sr-only">{label}</span> </span> </LinkButtonImpl> </WithTooltip> );}export { Button, IconButton, LinkButton, LinkIconButton, SplitButtonContext, buttonVariants };