/** * @title Slider * @description 지정된 범위 안에서 하나 또는 두 개의 값을 선택해 입력할 수 있는 컴포넌트예요. */import * as React from 'react';import { Slider as SliderPrimitive } from 'radix-ui';import { cva, type VariantProps } from 'class-variance-authority';import { CircleAlert } from 'lucide-react';import { cn } from '@/lib/utils';// SEED Rootage spec: slider.yaml, slider-thumb.yaml, slider-tick.yaml/** thumb 크기($dimension.x5 = 20px)의 절반. Radix 가 thumb 을 트랙 안쪽으로 보정하는 값과 같아요. */const THUMB_HALF = 10;/** 값의 위치(%)에 thumb 과 같은 보정을 적용한 CSS left 값. tick·marker 를 thumb 중심에 맞춰요. */function getInBoundsLeft(percent: number) { const offset = THUMB_HALF - (percent / 50) * THUMB_HALF; return `calc(${percent}% + ${offset}px)`;}function toPercent(value: number, min: number, max: number) { if (max === min) return 0; return ((value - min) / (max - min)) * 100;}const sliderTickVariants = cva( 'pointer-events-none absolute top-1/2 h-full -translate-x-1/2 -translate-y-1/2 bg-fg-on-neutral-solid', { variants: { weight: { thin: 'w-px', thick: 'w-x1', }, }, defaultVariants: { weight: 'thin', }, },);const sliderMarkerVariants = cva( 'absolute inset-y-0 w-max text-t3 font-regular text-fg-neutral-muted group-data-[disabled]/slider:text-fg-disabled', { variants: { align: { start: 'text-left', center: '-translate-x-1/2 text-center', end: '-translate-x-full text-right', }, }, defaultVariants: { align: 'center', }, },);const sliderValueIndicatorVariants = cva( [ 'pointer-events-none absolute bottom-[calc(100%+var(--rb-dimension-x3))] left-1/2 z-10 flex w-max -translate-x-1/2 flex-col items-center', 'min-w-[calc(var(--rb-dimension-x2)*2+var(--rb-dimension-x2))] rounded-r1_5 bg-bg-neutral-solid px-x2 py-x1', 'whitespace-pre-wrap text-center text-t3 font-medium text-fg-on-neutral-solid', 'origin-bottom scale-90 opacity-0 transition-[opacity,scale] duration-(--rb-duration-d4) ease-easing', // 키보드 포커스와 드래그 중에는 trigger 와 관계없이 항상 보여요. 'group-focus-visible/thumb:opacity-100 group-focus-visible/thumb:scale-100 group-focus-visible/thumb:ease-enter', 'group-data-[dragging]/slider:group-focus/thumb:opacity-100 group-data-[dragging]/slider:group-focus/thumb:scale-100 group-data-[dragging]/slider:group-focus/thumb:ease-enter', ], { variants: { trigger: { // 마우스 환경은 hover, 터치 환경은 active 로 보여요. (`hover:` 는 hover 가능한 기기에서만 적용돼요.) auto: [ 'group-hover/thumb:opacity-100 group-hover/thumb:scale-100 group-hover/thumb:ease-enter', 'group-active/thumb:opacity-100 group-active/thumb:scale-100 group-active/thumb:ease-enter', ], hover: 'group-hover/thumb:opacity-100 group-hover/thumb:scale-100 group-hover/thumb:ease-enter', active: 'group-active/thumb:opacity-100 group-active/thumb:scale-100 group-active/thumb:ease-enter', }, }, defaultVariants: { trigger: 'auto', }, },);type SliderMarker = | number | { value: number; label?: React.ReactNode; align?: VariantProps<typeof sliderMarkerVariants>['align']; };export interface SliderProps extends Omit< React.ComponentProps<typeof SliderPrimitive.Root>, 'value' | 'defaultValue' | 'onValueChange' | 'onValueCommit' | 'children' | 'asChild' > { /** 제어할 값 목록이에요. 값이 하나면 Single, 두 개면 Range 슬라이더가 돼요. */ values?: number[]; /** 비제어 방식의 초기 값 목록이에요. */ defaultValues?: number[]; /** 값이 바뀔 때마다 호출돼요. */ onValuesChange?: (values: number[]) => void; /** 조작을 마치고 손을 뗐을 때 호출돼요. */ onValuesCommit?: (values: number[]) => void; /** Slider 위에 표시할 라벨이에요. */ label?: React.ReactNode; /** 라벨 옆에 표시할 보조 텍스트예요. (예: "(선택)") */ indicator?: React.ReactNode; /** Slider 아래에 표시할 설명이에요. */ description?: React.ReactNode; /** `invalid` 일 때 설명 대신 표시할 오류 메시지예요. */ errorMessage?: React.ReactNode; /** 값이 유효하지 않은 상태예요. */ invalid?: boolean; /** 라벨 옆에 필수 표시(*)를 보여줘요. */ showRequiredIndicator?: boolean; /** 트랙 아래에 표시할 마커예요. 숫자만 넘기면 값 자체가 라벨이 돼요. */ markers?: SliderMarker[]; /** 트랙 위에 눈금을 표시할 값 목록이에요. */ ticks?: number[]; /** 눈금 두께. step 이 충분히 작으면 thin, 크거나 눈금에만 놓일 수 있으면 thick 을 써요. */ tickWeight?: VariantProps<typeof sliderTickVariants>['weight']; /** Active Track(range) 을 숨겨요. */ hideRange?: boolean; /** thumb 위의 Value Indicator 를 숨겨요. */ hideValueIndicator?: boolean; /** Value Indicator 가 표시되는 조건이에요. */ valueIndicatorTrigger?: VariantProps<typeof sliderValueIndicatorVariants>['trigger']; /** Value Indicator 에 표시할 내용을 바꿔요. */ getValueIndicatorLabel?: (params: { value: number; thumbIndex: number }) => React.ReactNode; /** 스크린 리더가 읽을 값 설명이에요. 단위나 의미를 덧붙여요. */ getAriaValuetext?: (value: number) => string; /** 각 thumb 의 접근성 이름이에요. Range 는 최소·최대처럼 역할을 구분해요. */ getAriaLabel?: (thumbIndex: number) => string; /** 각 thumb 을 설명하는 요소의 id 예요. */ getAriaLabelledby?: (thumbIndex: number) => string;}function Slider({ className, values: valuesProp, defaultValues, onValuesChange, onValuesCommit, min = 0, max = 100, step = 1, disabled, dir, label, indicator, description, errorMessage, invalid, showRequiredIndicator, markers = [], ticks = [], tickWeight, hideRange = false, hideValueIndicator = false, valueIndicatorTrigger = 'auto', getValueIndicatorLabel, getAriaValuetext, getAriaLabel, getAriaLabelledby, onPointerDown, onPointerUp, onLostPointerCapture, ...props}: SliderProps) { const [internalValues, setInternalValues] = React.useState<number[]>(defaultValues ?? [min]); const values = valuesProp ?? internalValues; const [dragging, setDragging] = React.useState(false); const labelId = React.useId(); const descriptionId = React.useId(); const handleValueChange = (next: number[]) => { if (valuesProp === undefined) setInternalValues(next); onValuesChange?.(next); }; const isRtl = dir === 'rtl'; const positionStyle = (percent: number): React.CSSProperties => isRtl ? { right: getInBoundsLeft(percent) } : { left: getInBoundsLeft(percent) }; const renderHeader = label || indicator; const showError = invalid && errorMessage; return ( <div data-slot="slider-field" data-disabled={disabled || undefined} data-invalid={invalid || undefined} className={cn('flex w-full flex-col gap-x2', className)} > {renderHeader && ( <div data-slot="slider-header" className="flex items-center gap-x1"> <span id={labelId} className="text-t4 font-medium text-fg-neutral"> {label} {showRequiredIndicator && <span className="ml-x0_5 text-fg-critical">*</span>} {indicator && <span className="ml-x1 font-regular text-fg-neutral-subtle">{indicator}</span>} </span> </div> )} <div data-slot="slider" data-disabled={disabled || undefined} data-dragging={dragging || undefined} className={cn( 'group/slider flex w-full touch-none select-none flex-col items-stretch gap-x0_5', 'data-[dragging]:cursor-grabbing data-[disabled]:cursor-not-allowed', )} > <SliderPrimitive.Root data-slot="slider-control" min={min} max={max} step={step} dir={dir} disabled={disabled} value={values} onValueChange={handleValueChange} onValueCommit={onValuesCommit} onPointerDown={(event) => { onPointerDown?.(event); if (!disabled) setDragging(true); }} onPointerUp={(event) => { onPointerUp?.(event); setDragging(false); }} onLostPointerCapture={(event) => { onLostPointerCapture?.(event); setDragging(false); }} className={cn( 'relative flex h-[26px] w-full items-center', // thumb 이 감싸진 Radix 래퍼: 탭 점프 시 부드럽게 이동하고 드래그 중에는 즉시 따라가요. '[&>span:has(>[role=slider])]:transition-[left,right] [&>span:has(>[role=slider])]:duration-(--rb-duration-d3) [&>span:has(>[role=slider])]:ease-easing', 'group-data-[dragging]/slider:[&>span:has(>[role=slider])]:transition-none', )} {...props} > <SliderPrimitive.Track data-slot="slider-track" className="relative h-x1 grow overflow-hidden rounded-full bg-palette-gray-400 data-[disabled]:bg-bg-disabled" > {!hideRange && ( <SliderPrimitive.Range data-slot="slider-range" className={cn( 'absolute h-full rounded-full bg-fg-neutral data-[disabled]:bg-fg-disabled', 'transition-[left,right] duration-(--rb-duration-d3) ease-easing group-data-[dragging]/slider:transition-none', )} /> )} {ticks.map((value) => ( <span key={value} data-slot="slider-tick" aria-hidden className={sliderTickVariants({ weight: tickWeight })} style={positionStyle(toPercent(value, min, max))} /> ))} </SliderPrimitive.Track> {values.map((value, index) => ( <SliderPrimitive.Thumb key={index} data-slot="slider-thumb" aria-label={getAriaLabel?.(index)} aria-labelledby={getAriaLabelledby?.(index) ?? (label && !getAriaLabel ? labelId : undefined)} aria-describedby={description ? descriptionId : undefined} aria-invalid={invalid || undefined} aria-valuetext={getAriaValuetext?.(value)} className={cn( 'group/thumb relative block size-x5 outline-none', 'after:absolute after:inset-0 after:cursor-grab after:rounded-full after:bg-bg-neutral-solid after:content-[""]', 'after:outline-2 after:outline-offset-2 after:outline-transparent focus-visible:after:outline-stroke-focus-ring', 'after:transition-[scale,outline-color] after:duration-(--rb-duration-d3) after:ease-easing', 'active:after:scale-[1.2] group-data-[dragging]/slider:focus:after:scale-[1.2]', 'data-[disabled]:after:cursor-not-allowed data-[disabled]:after:bg-fg-disabled data-[disabled]:active:after:scale-100', )} > {!hideValueIndicator && !disabled && ( <span data-slot="slider-value-indicator" aria-hidden className={sliderValueIndicatorVariants({ trigger: valueIndicatorTrigger })} > {getValueIndicatorLabel ? getValueIndicatorLabel({ value, thumbIndex: index }) : value} <svg data-slot="slider-value-indicator-arrow" viewBox="0 0 8 6" className="absolute top-full left-1/2 block h-x1_5 w-x2 -translate-x-1/2 fill-bg-neutral-solid" > <path d="M0 0h8L4.83 5.07a1 1 0 0 1-1.66 0z" /> </svg> </span> )} </SliderPrimitive.Thumb> ))} </SliderPrimitive.Root> {markers.length > 0 && ( <div data-slot="slider-markers" aria-hidden className="relative h-[var(--rb-line-height-t3)]"> {markers.map((marker) => { const m = typeof marker === 'number' ? { value: marker } : marker; const align = m.align ?? (m.value === min ? 'start' : m.value === max ? 'end' : 'center'); const rtlAlign = isRtl ? (align === 'start' ? 'end' : align === 'end' ? 'start' : align) : align; return ( <span key={m.value} data-slot="slider-marker" className={sliderMarkerVariants({ align: rtlAlign })} style={{ left: getInBoundsLeft(isRtl ? 100 - toPercent(m.value, min, max) : toPercent(m.value, min, max)) }} > {'label' in m && m.label !== undefined ? m.label : m.value} </span> ); })} </div> )} </div> {(description || showError) && ( <div data-slot="slider-footer" className="flex flex-col gap-x1"> {description && ( <span id={descriptionId} className={cn('text-t3 text-fg-neutral-subtle', showError && 'sr-only')}> {description} </span> )} {showError && ( <span className="flex items-center gap-x1 text-t3 text-fg-critical [&_svg]:size-x3_5 [&_svg]:shrink-0"> <CircleAlert aria-hidden /> {errorMessage} </span> )} </div> )} </div> );}export { Slider, sliderTickVariants, sliderMarkerVariants, sliderValueIndicatorVariants };