/** * @title Toggle * @description 설정을 즉시 켜거나 끌 때 쓰는 토글 스위치예요. */import * as React from 'react';import { Switch as SwitchPrimitive } from 'radix-ui';import { cva } from 'class-variance-authority';import { Check, X } from 'lucide-react';import { cn } from '@/lib/utils';// Atlassian @atlaskit/toggle — toggle / icon-container 스펙// 트랙: border 2px(투명, 포커스 시 border.focused) + padding 2px + content(regular 32×16, large 40×20)// 손잡이: bottom/left 4px, regular 12px · large 16px, 켜지면 space.200 / space.250 만큼 이동// 플래그 ON(platform_design_system_toggle_motion): 트랙 overflow hidden + 150ms --ds-input-toggle 전환,// 눌림 색(success/neutral bold pressed), 비활성이면 전환 없음, reduced-motion 이면 손잡이 전환 없음·아이콘은 opacity 만const LOADING_LABEL = ', 불러오는 중';const toggleVariants = cva( [ 'group/toggle relative m-ds-025 box-content inline-block shrink-0 cursor-pointer overflow-hidden p-ds-025 align-middle', 'rounded-ds-full border-(length:--ds-border-width-selected) border-solid border-transparent bg-clip-content', 'bg-ds-background-neutral-bold text-ds-icon-inverse', 'transition-[background-color,color] duration-(--ds-motion-input-toggle-duration) ease-(--ds-motion-input-toggle-curve)', 'outline-none focus-visible:border-(length:--ds-border-width-focused) focus-visible:border-ds-border-focused', 'enabled:hover:bg-ds-background-neutral-bold-hovered enabled:active:bg-ds-background-neutral-bold-pressed', 'data-[state=checked]:bg-ds-background-success-bold', 'data-[state=checked]:enabled:hover:bg-ds-background-success-bold-hovered data-[state=checked]:enabled:active:bg-ds-background-success-bold-pressed', 'forced-colors:transition-none disabled:transition-none', 'disabled:cursor-not-allowed disabled:bg-ds-background-disabled disabled:text-ds-icon-disabled data-[state=checked]:disabled:bg-ds-background-disabled', ], { variants: { size: { regular: 'h-ds-200 w-ds-400', large: 'h-ds-250 w-ds-500', }, }, defaultVariants: { size: 'regular' }, },);const thumbVariants = cva( [ 'pointer-events-none absolute bottom-[4px] left-[4px] z-[1] block rounded-ds-full bg-ds-icon-inverse', 'transition-transform duration-(--ds-motion-input-toggle-duration) ease-(--ds-motion-input-toggle-curve)', 'data-disabled:transition-none motion-reduce:transition-none forced-colors:transition-none', ], { variants: { size: { regular: 'size-ds-150 data-[state=checked]:translate-x-ds-200', large: 'size-ds-200 data-[state=checked]:translate-x-ds-250', }, }, defaultVariants: { size: 'regular' }, },);function ToggleSpinner() { // Atlaskit Spinner size="xsmall"(8px) appearance="invert" return ( <svg viewBox="0 0 16 16" className="size-2 animate-spin" fill="none" aria-hidden> <circle cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="2.5" strokeOpacity="0.3" /> <path d="M14 8A6 6 0 0 0 8 2" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" /> </svg> );}function IconContainer({ size, position, isHidden, children,}: { size: 'regular' | 'large'; position: 'left' | 'right'; isHidden: boolean; children: React.ReactNode;}) { return ( <span aria-hidden data-slot="toggle-icon" className={cn( 'pointer-events-none absolute top-ds-025 inline-flex items-center justify-center', 'transition-[opacity,translate] duration-(--ds-motion-input-toggle-duration) ease-(--ds-motion-input-toggle-curve)', 'motion-reduce:transition-[opacity] forced-colors:transition-none group-disabled/toggle:transition-none', size === 'regular' ? 'size-ds-200' : 'size-ds-250', position === 'left' ? 'left-[3px]' : 'right-[3px]', // 숨을 때는 가까운 트랙 끝으로 빠져나간다 isHidden && 'opacity-0', isHidden && (position === 'left' ? '-translate-x-full' : 'translate-x-full'), '[&_svg]:shrink-0', )} > {children} </span> );}export interface ToggleChangeEvent { /** 바뀐 뒤의 상태. (Atlaskit 의 e.currentTarget 과 같은 모양) */ currentTarget: { checked: boolean; name: string; value: string }; target: { checked: boolean; name: string; value: string };}export interface ToggleProps extends Omit< React.ComponentProps<typeof SwitchPrimitive.Root>, 'checked' | 'defaultChecked' | 'onCheckedChange' | 'onChange' | 'disabled' | 'value' | 'name' | 'children' | 'asChild' > { /** 비활성. 탭 순서에서도 빠져요. */ isDisabled?: boolean; /** 보이는 라벨과 연결할 때 `<label htmlFor>` 와 같은 값을 줘요. */ id?: string; name?: string; value?: string; /** 크기 */ size?: 'regular' | 'large'; /** 제어 모드의 상태. 있으면 defaultChecked 보다 우선해요. */ isChecked?: boolean; /** 비제어 모드의 초기 상태 */ defaultChecked?: boolean; /** 상태를 비동기로 불러오거나 저장하는 중일 때 아이콘 자리에 스피너를 보여줘요. */ isLoading?: boolean; /** 보이는 라벨을 둘 수 없을 때 쓰는 접근성 이름 */ label?: string; /** 동작을 더 설명하는 요소의 id (aria-describedby) */ descriptionId?: string; /** 상태가 바뀔 때 호출돼요. `e.currentTarget.checked` 로 새 값을 읽어요. */ onChange?: (event: ToggleChangeEvent) => void; testId?: string;}function Toggle({ isDisabled = false, id, name = '', value = '', size = 'regular', isChecked, defaultChecked = false, isLoading = false, label, descriptionId, onChange, testId, className, ...props}: ToggleProps) { const [checkedState, setCheckedState] = React.useState(defaultChecked); const isControlled = isChecked !== undefined; const checked = isControlled ? isChecked : checkedState; const handleCheckedChange = (next: boolean) => { if (!isControlled) setCheckedState(next); const detail = { checked: next, name, value }; onChange?.({ currentTarget: detail, target: detail }); }; const ariaLabel = label ? (isLoading ? `${label}${LOADING_LABEL}` : label) : undefined; return ( <SwitchPrimitive.Root data-slot="toggle" data-size={size} id={id} name={name || undefined} value={value || undefined} checked={checked} onCheckedChange={handleCheckedChange} disabled={isDisabled} aria-label={ariaLabel} aria-describedby={descriptionId} aria-busy={isLoading || undefined} data-testid={testId} className={cn(toggleVariants({ size }), className)} {...props} > <IconContainer size={size} position="left" isHidden={!checked}> {isLoading && checked ? ( <ToggleSpinner /> ) : ( <Check data-testid={testId && `${testId}--toggle-check-icon`} className="size-3" strokeWidth={3} /> )} </IconContainer> <IconContainer size={size} position="right" isHidden={checked}> {isLoading && !checked ? ( <ToggleSpinner /> ) : ( <X data-testid={testId && `${testId}--toggle-cross-icon`} className="size-3" strokeWidth={3} /> )} </IconContainer> {isLoading && !label && <span className="sr-only">{LOADING_LABEL}</span>} <SwitchPrimitive.Thumb data-slot="toggle-thumb" className={thumbVariants({ size })} /> </SwitchPrimitive.Root> );}export { Toggle, toggleVariants };