/* ============================================================================
* 서명전에 — 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 ? (
{renderTooltip()}
) : 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 (
{message}
);
}
Slider.Tooltip = SliderTooltip;
window.Slider = Slider;
window.SliderTooltip = SliderTooltip;
Object.assign(window, { Slider, SliderTooltip });