/** * @title Wheel Picker * @description 항목을 세로로 스크롤해 하나 이상의 열에서 값을 선택하는 입력 컴포넌트예요. */'use client';import * as React from 'react';import { cva } from 'class-variance-authority';import { cn } from '@/lib/utils';// SEED Rootage spec: wheel-picker.yaml// - root: height small 180px / medium 220px, color $color.bg.layer-floating// - selectionIndicator: insetX $dimension.x4, cornerRadius $radius.r2, color $color.bg.neutral-weak// - scrollFog: min(root × 40%, item × 3) (small 108px / medium 132px)// - item: height small 36px / medium 44px// - itemLabel: paddingX $dimension.x4, font-weight medium, small t7-static / medium t10-static,// color $color.fg.disabled → selected $color.fg.neutralexport type WheelPickerSize = 'small' | 'medium';const ITEM_SIZE: Record<WheelPickerSize, number> = { small: 36, medium: 44 };const ROOT_HEIGHT: Record<WheelPickerSize, number> = { small: 180, medium: 220 };const SCROLL_FOG_FRACTION = 0.4;const SETTLE_DELAY = 120;const DRAG_THRESHOLD = 4;const wheelPickerItemLabelVariants = cva( 'box-border flex h-full shrink-0 items-center justify-center whitespace-nowrap px-x4 font-medium tabular-nums', { variants: { size: { small: 'text-t7-static', medium: 'text-t10-static', }, }, defaultVariants: { size: 'medium' }, },);export interface WheelPickerOption { /** 항목을 식별하고 선택 상태로 주고받는 고유 값이에요. */ value: string; /** 항목에 표시할 내용이에요. */ label: React.ReactNode; /** React 요소 label 을 스크린 리더에서 읽을 문자열이에요. */ ariaLabel?: string;}export interface WheelPickerColumn { /** 컬럼을 식별하는 고유 값이에요. */ id: string; /** 컬럼에 표시할 선택 항목이에요. 하나 이상 제공해요. */ options: readonly WheelPickerOption[]; /** 선택된 값 (제어). */ value?: string; /** 처음 선택될 값 (비제어). 생략하면 첫 항목이에요. */ defaultValue?: string; /** 스크롤이 항목에 정착해 값이 확정되면 호출돼요. */ onValueChange?: (value: string) => void; /** 스크롤 중 중앙 항목이 바뀔 때마다 호출돼요. 정착 전에도 호출되므로 햅틱 등에 사용해요. */ onIndexChange?: (index: number, value: string) => void; /** 마지막 값 다음에 첫 값이 이어져요. */ loop?: boolean; /** 외부에서 value 가 바뀔 때 스크롤 방식이에요. */ valueChangeBehavior?: 'auto' | 'smooth'; /** 항목 라벨의 가로 정렬이에요. */ itemAlign?: 'start' | 'center' | 'end'; /** 이 컬럼만 비활성화해요. */ disabled?: boolean; /** 이 컬럼만 읽기 전용으로 만들어요. */ readOnly?: boolean; /** 기본 Item Label 을 대체해요. */ renderLabel?: (option: WheelPickerOption, state: { selected: boolean }) => React.ReactNode; /** 접근성 텍스트를 직접 계산해요. */ getAriaValueText?: (option: WheelPickerOption) => string; className?: string; 'aria-label'?: string; 'aria-labelledby'?: string;}export interface WheelPickerProps extends Omit<React.ComponentProps<'div'>, 'children'> { /** Wheel Picker 를 구성하는 컬럼 목록이에요. */ columns: readonly WheelPickerColumn[]; /** 크기예요. Medium 이 범용, Small 은 작은 공간용이에요. */ size?: WheelPickerSize; /** 한 화면에 보이는 항목 수예요. 양의 홀수를 사용해요. 지정하면 Root 높이를 대체해요. */ visibleItemCount?: number; /** 항목 높이(px)예요. 생략하면 size 에 맞는 값을 사용해요. */ itemSize?: number; /** 모든 컬럼의 포커스와 조작을 막아요. */ disabled?: boolean; /** 포커스는 유지하면서 값 변경을 막아요. */ readOnly?: boolean;}interface WheelPickerContextValue { size: WheelPickerSize; itemSize: number; visibleItemCount: number; disabled: boolean; readOnly: boolean;}const WheelPickerContext = React.createContext<WheelPickerContextValue | null>(null);function useWheelPickerContext() { const context = React.useContext(WheelPickerContext); if (!context) throw new Error('WheelPickerColumn 은 WheelPicker 안에서 사용해요.'); return context;}function optionText(option: WheelPickerOption) { if (option.ariaLabel) return option.ariaLabel; if (typeof option.label === 'string' || typeof option.label === 'number') return String(option.label); return option.value;}const mod = (n: number, m: number) => ((n % m) + m) % m;function WheelPickerColumnView({ column }: { column: WheelPickerColumn }) { const ctx = useWheelPickerContext(); const { options, value: valueProp, defaultValue, onValueChange, onIndexChange, loop = false, valueChangeBehavior = 'auto', itemAlign = 'center', renderLabel, getAriaValueText, className, } = column; const disabled = ctx.disabled || Boolean(column.disabled); const readOnly = ctx.readOnly || Boolean(column.readOnly); const { itemSize } = ctx; const length = options.length; const [uncontrolledValue, setUncontrolledValue] = React.useState(defaultValue ?? options[0]?.value); const isControlled = valueProp !== undefined; const value = isControlled ? valueProp : uncontrolledValue; const valueIndex = Math.max( 0, options.findIndex((option) => option.value === value), ); // loop 는 같은 목록을 여러 벌 이어 붙이고, 정착할 때 가운데 벌로 조용히 되돌려요. const copies = loop && length > 0 ? Math.max(3, 2 * Math.ceil((ctx.visibleItemCount * 2) / length) + 1) : 1; const middleCopy = Math.floor(copies / 2); const baseIndex = middleCopy * length; const total = length * copies; const ref = React.useRef<HTMLDivElement>(null); const [centerIndex, setCenterIndex] = React.useState(baseIndex + valueIndex); const centerRef = React.useRef(centerIndex); const valueRef = React.useRef(value); valueRef.current = value; const settleTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null); const targetRef = React.useRef<number | null>(null); const dragRef = React.useRef<{ pointerId: number; startY: number; startScroll: number; moved: boolean } | null>( null, ); const suppressClickRef = React.useRef(false); const [dragging, setDragging] = React.useState(false); const mountedRef = React.useRef(false); const callbacks = React.useRef({ onValueChange, onIndexChange }); callbacks.current = { onValueChange, onIndexChange }; const updateCenter = React.useCallback( (index: number) => { if (index === centerRef.current) return; centerRef.current = index; setCenterIndex(index); const option = options[mod(index, length)]; if (option) callbacks.current.onIndexChange?.(mod(index, length), option.value); }, [options, length], ); const scrollToIndex = React.useCallback( (index: number, behavior: ScrollBehavior) => { const el = ref.current; if (!el) return; targetRef.current = index; el.scrollTo({ top: index * itemSize, behavior }); }, [itemSize], ); const settle = React.useCallback(() => { settleTimer.current = null; targetRef.current = null; const el = ref.current; if (!el || dragRef.current || length === 0) return; let index = Math.min(total - 1, Math.max(0, Math.round(el.scrollTop / itemSize))); const optionIndex = mod(index, length); if (loop && Math.floor(index / length) !== middleCopy) { index = baseIndex + optionIndex; el.scrollTop = index * itemSize; centerRef.current = index; setCenterIndex(index); } const next = options[optionIndex]; if (next && next.value !== valueRef.current) { valueRef.current = next.value; if (!isControlled) setUncontrolledValue(next.value); callbacks.current.onValueChange?.(next.value); } }, [baseIndex, isControlled, itemSize, length, loop, middleCopy, options, total]); const handleScroll = () => { const el = ref.current; if (!el || length === 0) return; updateCenter(Math.min(total - 1, Math.max(0, Math.round(el.scrollTop / itemSize)))); if (settleTimer.current) clearTimeout(settleTimer.current); settleTimer.current = setTimeout(settle, SETTLE_DELAY); }; // 외부 value(또는 options) 변경을 스크롤 위치에 반영해요. 조작 중에는 건너뛰어요. React.useLayoutEffect(() => { const el = ref.current; if (!el || length === 0) return; if (mountedRef.current && (settleTimer.current || dragRef.current)) return; const current = Math.round(el.scrollTop / itemSize); if (mountedRef.current && current < total && mod(current, length) === valueIndex) { centerRef.current = current; setCenterIndex(current); return; } const target = baseIndex + valueIndex; const behavior: ScrollBehavior = mountedRef.current && valueChangeBehavior === 'smooth' ? 'smooth' : 'auto'; if (behavior === 'auto') el.scrollTop = target * itemSize; else el.scrollTo({ top: target * itemSize, behavior }); centerRef.current = target; setCenterIndex(target); mountedRef.current = true; }, [valueIndex, baseIndex, itemSize, length, total, valueChangeBehavior]); React.useEffect( () => () => { if (settleTimer.current) clearTimeout(settleTimer.current); }, [], ); const interactive = !disabled && !readOnly; const move = (getNext: (from: number) => number) => { if (!interactive || length === 0) return; const from = targetRef.current ?? centerRef.current; const next = getNext(from); if (next === from) return; scrollToIndex(next, 'smooth'); }; const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => { const clamp = (i: number) => Math.min(total - 1, Math.max(0, i)); const copyStart = (from: number) => Math.floor(from / length) * length; switch (event.key) { case 'ArrowUp': event.preventDefault(); move((from) => (loop ? clamp(from - 1) : Math.max(0, from - 1))); break; case 'ArrowDown': event.preventDefault(); move((from) => (loop ? clamp(from + 1) : Math.min(length - 1, from + 1))); break; case 'Home': event.preventDefault(); move((from) => copyStart(from)); break; case 'End': event.preventDefault(); move((from) => copyStart(from) + length - 1); break; } }; // 마우스 드래그: 일정 거리 이상 움직이면 드래그로 보고, 놓으면 가장 가까운 항목에 정렬해요. const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => { if (!interactive || event.pointerType !== 'mouse' || event.button !== 0) return; const el = ref.current; if (!el) return; dragRef.current = { pointerId: event.pointerId, startY: event.clientY, startScroll: el.scrollTop, moved: false }; }; const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => { const drag = dragRef.current; const el = ref.current; if (!drag || !el || drag.pointerId !== event.pointerId) return; const delta = event.clientY - drag.startY; if (!drag.moved && Math.abs(delta) > DRAG_THRESHOLD) { drag.moved = true; setDragging(true); el.setPointerCapture(event.pointerId); } if (drag.moved) el.scrollTop = drag.startScroll - delta; }; const endDrag = (event: React.PointerEvent<HTMLDivElement>) => { const drag = dragRef.current; const el = ref.current; if (!drag || drag.pointerId !== event.pointerId) return; dragRef.current = null; if (!drag.moved || !el) return; if (el.hasPointerCapture(event.pointerId)) el.releasePointerCapture(event.pointerId); setDragging(false); suppressClickRef.current = true; setTimeout(() => { suppressClickRef.current = false; }, 0); scrollToIndex(Math.min(total - 1, Math.max(0, Math.round(el.scrollTop / itemSize))), 'smooth'); }; if (length === 0) return null; const selectedOption = options[mod(centerIndex, length)] ?? options[valueIndex]; const ariaValueText = selectedOption ? (getAriaValueText?.(selectedOption) ?? optionText(selectedOption)) : undefined; return ( <div ref={ref} data-slot="wheel-picker-column" data-column-id={column.id} role="spinbutton" tabIndex={disabled ? -1 : 0} aria-label={column['aria-label']} aria-labelledby={column['aria-labelledby']} aria-valuemin={0} aria-valuemax={length - 1} aria-valuenow={mod(centerIndex, length)} aria-valuetext={ariaValueText} aria-disabled={disabled || undefined} aria-readonly={readOnly || undefined} data-disabled={disabled || undefined} data-readonly={readOnly || undefined} data-dragging={dragging || undefined} onScroll={handleScroll} onKeyDown={handleKeyDown} onPointerDown={handlePointerDown} onPointerMove={handlePointerMove} onPointerUp={endDrag} onPointerCancel={endDrag} className={cn( 'group/column box-border h-full w-max flex-none overflow-x-hidden overflow-y-auto outline-none', 'snap-y snap-mandatory overscroll-x-none overscroll-y-contain touch-pan-y', '[scrollbar-width:none] [&::-webkit-scrollbar]:hidden', 'cursor-grab data-dragging:cursor-grabbing data-dragging:snap-none', 'data-readonly:cursor-default data-readonly:overflow-y-hidden', 'data-disabled:cursor-default data-disabled:overflow-y-hidden', className, )} > <div aria-hidden className="pointer-events-none" style={{ height: `calc(50% - ${itemSize / 2}px)` }} /> {Array.from({ length: total }, (_, index) => { const option = options[mod(index, length)]!; const selected = index === centerIndex; return ( <div key={index} data-slot="wheel-picker-item" data-selected={selected || undefined} aria-hidden onClick={() => { if (!interactive || suppressClickRef.current) return; scrollToIndex(index, 'smooth'); }} style={{ height: itemSize, minHeight: itemSize }} className={cn( 'flex snap-center items-center select-none', itemAlign === 'start' && 'justify-start', itemAlign === 'center' && 'justify-center', itemAlign === 'end' && 'justify-end', 'text-fg-disabled data-selected:text-fg-neutral', 'group-data-disabled/column:text-fg-disabled', 'rounded-r2 outline-2 -outline-offset-2 outline-transparent transition-[outline-color] duration-(--rb-duration-color-transition) ease-easing', 'group-focus-visible/column:data-selected:outline-stroke-focus-ring', )} > {renderLabel ? ( renderLabel(option, { selected }) ) : ( <WheelPickerItemLabel size={ctx.size}>{option.label}</WheelPickerItemLabel> )} </div> ); })} <div aria-hidden className="pointer-events-none" style={{ height: `calc(50% - ${itemSize / 2}px)` }} /> </div> );}export interface WheelPickerItemLabelProps extends React.ComponentProps<'span'> { size?: WheelPickerSize;}/** 기본 Item Label 이에요. `renderLabel` 에서 기본 여백과 글자 스타일을 유지할 때 다시 조합해요. */function WheelPickerItemLabel({ size, className, ...props }: WheelPickerItemLabelProps) { const ctx = React.useContext(WheelPickerContext); return ( <span data-slot="wheel-picker-item-label" className={cn(wheelPickerItemLabelVariants({ size: size ?? ctx?.size }), className)} {...props} /> );}/** * 여러 Wheel Picker 컬럼을 데이터 배열로 구성해요. */function WheelPicker({ columns, size = 'medium', visibleItemCount, itemSize: itemSizeProp, disabled = false, readOnly = false, className, style, ...props}: WheelPickerProps) { const itemSize = itemSizeProp ?? ITEM_SIZE[size]; const height = visibleItemCount ? visibleItemCount * itemSize : ROOT_HEIGHT[size]; const fogMax = itemSize * 3; const fog = `min(${SCROLL_FOG_FRACTION * 100}%, ${fogMax}px)`; const maskImage = `linear-gradient(to bottom, transparent 0, black ${fog}, black calc(100% - ${fog}), transparent 100%)`; const context = React.useMemo<WheelPickerContextValue>( () => ({ size, itemSize, visibleItemCount: visibleItemCount ?? Math.round(height / itemSize), disabled, readOnly, }), [size, itemSize, visibleItemCount, height, disabled, readOnly], ); return ( <WheelPickerContext.Provider value={context}> <div data-slot="wheel-picker" role="group" data-size={size} data-disabled={disabled || undefined} className={cn('relative h-(--wheel-picker-height) w-full overflow-hidden bg-bg-layer-floating', className)} style={{ '--wheel-picker-height': `${height}px`, ...style } as React.CSSProperties} {...props} > <div aria-hidden data-slot="wheel-picker-selection-indicator" className="pointer-events-none absolute inset-x-x4 top-1/2 z-0 -translate-y-1/2 rounded-r2 bg-bg-neutral-weak" style={{ height: itemSize }} /> <div data-slot="wheel-picker-scroll-fog" className="relative z-[1] h-full overflow-hidden" style={{ maskImage, WebkitMaskImage: maskImage }} > <div data-slot="wheel-picker-columns" className="flex h-full w-full justify-center"> {columns.map((column) => ( <WheelPickerColumnView key={column.id} column={column} /> ))} </div> </div> </div> </WheelPickerContext.Provider> );}export { WheelPicker, WheelPickerItemLabel, wheelPickerItemLabelVariants };