/** * @title Contextual Floating Button * @description 화면 위에 떠 있으며 특정 상황에서만 나타나는 보조 액션을 위한 버튼이에요. */import * as React from 'react';import { Slot } from 'radix-ui';import { cva, type VariantProps } from 'class-variance-authority';import { cn } from '@/lib/utils';// SEED Rootage spec: contextual-floating-button.yamlconst contextualFloatingButtonVariants = cva( [ 'relative inline-flex shrink-0 cursor-pointer select-none items-center justify-center rounded-full shadow-s3 antialiased', 'text-t4 font-medium', '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', 'enabled:active:scale-[0.97] data-[loading]:active:scale-100', 'disabled:cursor-not-allowed disabled:bg-bg-disabled disabled:text-fg-disabled', '[&_svg]:pointer-events-none [&_svg]:shrink-0', ], { variants: { variant: { // 배경과 대비되는 강조된 보조 액션 solid: [ 'bg-bg-neutral-solid text-fg-on-neutral-solid', 'enabled:active:bg-bg-neutral-solid-pressed data-[loading]:bg-bg-neutral-solid-pressed', ], // 시각적 부담 없이 부드럽게 액션을 유도 layer: [ 'bg-bg-layer-floating text-fg-neutral', 'enabled:active:bg-bg-layer-floating-pressed data-[loading]:bg-bg-layer-floating-pressed', ], }, layout: { withText: 'min-h-[36px] gap-x1 px-x3_5 py-x2 [&_svg]:size-[16px]', iconOnly: 'size-[44px] [&_svg]:size-[22px]', }, }, defaultVariants: { variant: 'solid', layout: 'withText', }, },);const progressColors = { solid: { track: 'stroke-palette-gray-700', range: 'stroke-fg-on-neutral-solid' }, layer: { track: 'stroke-palette-gray-500', range: 'stroke-fg-neutral' },} as const;function ProgressCircle({ variant }: { variant: keyof typeof progressColors }) { // progressCircle: size 16px, thickness 2px return ( <svg className="size-[16px] animate-spin" viewBox="0 0 16 16" fill="none" aria-hidden style={{ width: 16, height: 16 }} > <circle cx="8" cy="8" r="7" className={progressColors[variant].track} strokeWidth="2" /> <path d="M8 1a7 7 0 0 1 7 7" className={progressColors[variant].range} strokeWidth="2" strokeLinecap="round" /> </svg> );}export interface ContextualFloatingButtonProps extends React.ComponentProps<'button'>, VariantProps<typeof contextualFloatingButtonVariants> { asChild?: boolean; /** 로딩 중에는 내용을 숨기고 Progress Circle 을 보여주며 클릭을 막아요. */ loading?: boolean;}function ContextualFloatingButton({ className, variant, layout, asChild = false, loading = false, disabled, type, children, ...props}: ContextualFloatingButtonProps) { const Comp = asChild ? Slot.Root : 'button'; return ( <Comp data-slot="contextual-floating-button" data-loading={loading || undefined} aria-busy={loading || undefined} type={asChild ? undefined : (type ?? 'button')} disabled={disabled} className={cn( contextualFloatingButtonVariants({ variant, layout }), loading && 'pointer-events-none', className, )} {...props} > {asChild ? ( children ) : ( <> <span className={cn('inline-flex items-center gap-[inherit]', loading && 'invisible')}>{children}</span> {loading && ( <span className="absolute inset-0 flex items-center justify-center"> <ProgressCircle variant={variant ?? 'solid'} /> </span> )} </> )} </Comp> );}export { ContextualFloatingButton, contextualFloatingButtonVariants };