/* ============================================================================ * 서명전에 — TDS Slider 컴포넌트 * · 막대를 좌우로 움직여서 원하는 숫자를 선택할 수 있는 컨트롤이에요. * · 2 서브: * Slider — 본체 (track + fill + handle + label + tooltip) * Slider.Tooltip — 핸들 위에 표시되는 말풍선 (요소 자체로 전달) * * · controlled / uncontrolled 모두 지원: * controlled — value + onValueChange(v) * uncontrolled — defaultValue + 내부 useState * * · props: * minValue (number, 기본 0) * maxValue (number, 기본 100) * step (number, 기본 1) ← 스펙에는 없지만 키보드/클릭 정합 위해 노출 * color (CSS color, 기본 var(--tds-blue-500)) * label ({ min: string, max: string, mid?: string }) * tooltip (React.ReactElement, 보통 ) * disabled (boolean) * * · 상호작용: * · 트랙 / 핸들 위에서 pointerdown → setPointerCapture → pointermove 추적 * · 트랙 클릭 시 클릭 위치로 즉시 점프 (드래그 전이도 됨) * · 키보드: * ← ↓ : -step → ↑ : +step * PageDown: -step×10 PageUp: +step×10 * Home: minValue End: maxValue * · disabled 시 모든 인터랙션 차단 + 스타일 흐리게 * * · 접근성: * role="slider", aria-valuemin / aria-valuemax / aria-valuenow, * aria-orientation="horizontal", tabIndex=0, * touch-action: none (모바일 가로 스와이프 방지), * 라벨이 있으면 aria-valuetext = `${valuenow}` (스펙 단순화), * 외부 aria-label / aria-labelledby 그대로 전달. * * · color 변수 주입: * --tds-slider-color (인라인 style) → CSS 가 fill / handle ring / tooltip 에 사용. * ========================================================================== */ function __sliderClampRound(raw, min, max, step) { let v = Math.max(min, Math.min(max, raw)); if (step > 0) { // step 기준으로 스냅 — min 부터 떨어진 거리를 step 으로 round const snapped = Math.round((v - min) / step) * step + min; v = Math.max(min, Math.min(max, snapped)); } return v; } function Slider({ value, defaultValue, onValueChange, minValue = 0, maxValue = 100, step = 1, color, label, tooltip, disabled = false, className = "", style, id, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, ...rest }) { // ----- props 검증 ----- if (typeof minValue !== "number" || typeof maxValue !== "number" || minValue >= maxValue) { if (window.console && console.warn) { console.warn( `[Slider] minValue (${minValue}) 는 maxValue (${maxValue}) 보다 작아야 해요.` ); } } if (typeof step !== "number" || step <= 0) { if (window.console && console.warn) { console.warn(`[Slider] step 은 양수여야 해요. 받은 값: ${step}. 1 사용.`); } step = 1; } // ----- controlled vs uncontrolled ----- const isControlled = value !== undefined; const [internal, setInternal] = React.useState(function () { const init = typeof defaultValue === "number" ? defaultValue : minValue; return __sliderClampRound(init, minValue, maxValue, step); }); const currentValue = isControlled ? __sliderClampRound(value, minValue, maxValue, step) : internal; if (isControlled && typeof onValueChange !== "function") { if (window.console && console.warn) { console.warn( "[Slider] value 만 주고 onValueChange 는 안 줬어요. " + "controlled 모드에서는 onValueChange 로 외부 상태를 갱신해야 해요." ); } } const commitValue = function (next) { const v = __sliderClampRound(next, minValue, maxValue, step); if (v === currentValue) return; if (!isControlled) setInternal(v); if (typeof onValueChange === "function") onValueChange(v); }; // ----- 진행률 (0 ~ 1) ----- const denom = maxValue - minValue; const ratio = denom > 0 ? (currentValue - minValue) / denom : 0; const percent = Math.max(0, Math.min(1, ratio)) * 100; // ----- 트랙 → 좌표 헬퍼 ----- const trackRef = React.useRef(null); const draggingRef = React.useRef(false); const pointerIdRef = React.useRef(null); const valueFromClientX = function (clientX) { const el = trackRef.current; if (!el) return currentValue; const rect = el.getBoundingClientRect(); if (rect.width <= 0) return currentValue; const r = (clientX - rect.left) / rect.width; const clamped = Math.max(0, Math.min(1, r)); return minValue + clamped * (maxValue - minValue); }; // ----- 포인터 이벤트 ----- const handlePointerDown = function (e) { if (disabled) return; // 좌클릭 / 터치 / 펜만 처리 if (e.button !== undefined && e.button !== 0) return; const el = trackRef.current; if (!el) return; try { el.setPointerCapture(e.pointerId); } catch (_) { /* 일부 브라우저에서 실패 가능 — 무시 */ } draggingRef.current = true; pointerIdRef.current = e.pointerId; // 즉시 점프 commitValue(valueFromClientX(e.clientX)); e.preventDefault(); }; const handlePointerMove = function (e) { if (!draggingRef.current) return; if (pointerIdRef.current !== null && e.pointerId !== pointerIdRef.current) return; commitValue(valueFromClientX(e.clientX)); }; const handlePointerUp = function (e) { if (!draggingRef.current) return; if (pointerIdRef.current !== null && e.pointerId !== pointerIdRef.current) return; draggingRef.current = false; pointerIdRef.current = null; const el = trackRef.current; if (el) { try { el.releasePointerCapture(e.pointerId); } catch (_) {} } }; // ----- 키보드 ----- const handleKeyDown = function (e) { if (disabled) return; const k = e.key; let next = currentValue; if (k === "ArrowLeft" || k === "ArrowDown") { next = currentValue - step; } else if (k === "ArrowRight" || k === "ArrowUp") { next = currentValue + step; } else if (k === "PageDown") { next = currentValue - step * 10; } else if (k === "PageUp") { next = currentValue + step * 10; } else if (k === "Home") { next = minValue; } else if (k === "End") { next = maxValue; } else { return; } e.preventDefault(); commitValue(next); }; // ----- 색 주입 — CSS 변수 --tds-slider-color ----- const wrapStyle = { ...(style || {}) }; if (color) { wrapStyle["--tds-slider-color"] = color; } // ----- 라벨 평가 ----- const hasLabel = label && (label.min || label.max || label.mid); // ----- 툴팁 — message 만 받아 우리가 위치를 잡음 ----- const renderTooltip = function () { if (!tooltip) return null; if (!React.isValidElement(tooltip)) { if (window.console && console.warn) { console.warn("[Slider] tooltip 은 React 요소여야 해요. (예: )"); } return null; } // 자식의 className/style 에 위치를 주입 — 핸들 가운데 위에 정렬 const childClass = tooltip.props && tooltip.props.className ? tooltip.props.className : ""; const childStyle = tooltip.props && tooltip.props.style ? tooltip.props.style : {}; const merged = { ...childStyle, left: `${percent}%`, }; return React.cloneElement(tooltip, { className: ["tds-slider__tooltip-slot", childClass].filter(Boolean).join(" "), style: merged, }); }; // ----- 클래스 조립 ----- const wrapCls = [ "tds-slider", disabled ? "tds-slider--disabled" : "", hasLabel ? "tds-slider--has-label" : "", tooltip ? "tds-slider--has-tooltip" : "", className, ] .filter(Boolean) .join(" "); return (
{tooltip ? ( ) : null}
{hasLabel ? (
{label.min || ""} {label.mid ? ( {label.mid} ) : null} {label.max || ""}
) : null}
); } // ============================================================================= // Slider.Tooltip // · 핸들 위에 떠 있는 어두운 말풍선 + 아래로 향한 화살표. // · message: string (필수) // · 위치는 부모 Slider 가 React.cloneElement 로 left% 를 주입함. // · Tooltip 컴포넌트 자체와 이름이 겹치지 않도록 SliderTooltip 으로 노출. // ============================================================================= function SliderTooltip({ message, className = "", style, id, ...rest }) { if (typeof message !== "string" || message.length === 0) { if (window.console && console.warn) { console.warn("[Slider.Tooltip] message 는 비어있지 않은 string 이어야 해요."); } } const cls = ["tds-slider__tooltip", className].filter(Boolean).join(" "); return ( ); } Slider.Tooltip = SliderTooltip; window.Slider = Slider; window.SliderTooltip = SliderTooltip; Object.assign(window, { Slider, SliderTooltip });