/* ============================================================================ * 서명전에 — TDS SplitTextField 컴포넌트 (Task #168) * * · 두 개의 입력을 가로로 나란히 배치한 split 입력 컨테이너. * 한국 주민등록번호처럼 "앞 6 - 뒤 7" 같은 정형 포맷에 사용. * * · 서브 컴포넌트: * SplitTextField.RRN13 — 13자리 주민번호 전체 (앞6 + 뒤7) * · 첫 번째 필드 6자리 입력 완료 시 두 번째로 자동 포커스 이동 * · 두 번째 필드는 mask=true (기본) 면 ••• 로 마스킹 * SplitTextField.RRNFirst7 — 앞 7자리만 (앞6 + 성별 1자리) * · 두 번째 필드는 1자리 (성별) * · mask=false (기본) 라 성별 코드는 평문 표시 * * · 공통 props: * value 13자리 (RRN13) 또는 7자리 (RRNFirst7) string * defaultValue uncontrolled 초기값 * onChange (full: string) => void * hasError, handleError, disabled, focused, label, help * first { placeholder, autoComplete, ... } — 앞 필드에 패스스루 * second { placeholder, autoComplete, ... } — 뒤 필드에 패스스루 * * · ref API: * focus() 첫 번째 필드 포커스 * focusSecond() 두 번째 필드 포커스 * clear() 두 필드 모두 비우기 * el() { first, second } DOM 반환 * * · Babel Standalone 환경 호환: __stf 접두사 alias 사용. * ========================================================================== */ const __stfUseState = (typeof React !== "undefined" && React.useState) || function (v) { return [v, function () {}]; }; const __stfUseRef = (typeof React !== "undefined" && React.useRef) || function () { return { current: null }; }; const __stfUseEffect = (typeof React !== "undefined" && React.useEffect) || function () {}; const __stfUseImperativeHandle= (typeof React !== "undefined" && React.useImperativeHandle) || function () {}; const __stfForwardRef = (typeof React !== "undefined" && React.forwardRef) || function (fn) { return fn; }; function __stfJoin() { let out = ""; for (let i = 0; i < arguments.length; i++) { const v = arguments[i]; if (!v) continue; out = out ? out + " " + v : v; } return out; } // ----- 숫자만 추출 ----- function __stfDigits(s) { if (!s) return ""; return String(s).replace(/[^0-9]/g, ""); } // ----- bullet 문자열 만들기 ----- function __stfBullets(n) { let out = ""; for (let i = 0; i < n; i++) out += "•"; return out; } /* ---------------------------------------------------------------------------- * 내부 공용 — split 본체 * firstLen : 앞 필드 자릿수 (6) * secondLen: 뒤 필드 자릿수 (7 or 1) * mask : 뒤 필드를 ••• 로 가릴지 * sep : 시각 구분자 ("-" 기본) * first / second props : 패스스루 * -------------------------------------------------------------------------- */ const __SplitBase = __stfForwardRef(function __SplitBase(props, ref) { const { firstLen, secondLen, mask = false, sep = "-", label, help, hasError = false, handleError, disabled = false, focused: focusedProp, value, defaultValue, onChange, first, second, className = "", style, id, } = props; const isControlled = value !== undefined; const splitValue = function (full) { const digits = __stfDigits(full); return { first: digits.slice(0, firstLen), second: digits.slice(firstLen, firstLen + secondLen), }; }; const initial = splitValue(typeof defaultValue === "string" ? defaultValue : ""); const [internalFirst, setInternalFirst] = __stfUseState(initial.first); const [internalSecond, setInternalSecond] = __stfUseState(initial.second); const ext = isControlled ? splitValue(value) : null; const firstVal = isControlled ? ext.first : internalFirst; const secondVal = isControlled ? ext.second : internalSecond; const [focused, setFocused] = __stfUseState(false); const isFocused = focusedProp != null ? focusedProp : focused; const firstRef = __stfUseRef(null); const secondRef = __stfUseRef(null); __stfUseImperativeHandle(ref, function () { return { focus: function () { if (firstRef.current && firstRef.current.focus) firstRef.current.focus(); }, focusSecond: function () { if (secondRef.current && secondRef.current.focus) secondRef.current.focus(); }, clear: function () { if (!isControlled) { setInternalFirst(""); setInternalSecond(""); } if (typeof onChange === "function") onChange(""); if (firstRef.current && firstRef.current.focus) firstRef.current.focus(); }, el: function () { return { first: firstRef.current, second: secondRef.current }; }, }; }, [isControlled]); // ----- onChange emit ----- function emitChange(nextFirst, nextSecond) { const full = (nextFirst || "") + (nextSecond || ""); if (typeof onChange === "function") onChange(full); } // ----- 첫 번째 입력 ----- const handleFirst = function (e) { let next = __stfDigits(e.target.value).slice(0, firstLen); if (!isControlled) setInternalFirst(next); emitChange(next, secondVal); // 자릿수 채워지면 두 번째로 자동 포커스 if (next.length >= firstLen && secondRef.current && secondRef.current.focus) { secondRef.current.focus(); } }; // ----- 두 번째 입력 ----- const handleSecond = function (e) { let next = __stfDigits(e.target.value).slice(0, secondLen); if (!isControlled) setInternalSecond(next); emitChange(firstVal, next); }; // ----- 두 번째에서 백스페이스 + 비어있으면 첫 번째로 ----- const handleSecondKeyDown = function (e) { if (e.key === "Backspace" && (!secondVal || secondVal.length === 0)) { if (firstRef.current && firstRef.current.focus) { e.preventDefault(); firstRef.current.focus(); // 커서를 끝으로 setTimeout(function () { try { const len = (firstVal || "").length; firstRef.current.setSelectionRange(len, len); } catch (_e) {} }, 0); } } }; const handleFocusIn = function () { setFocused(true); }; const handleFocusOut = function () { setFocused(false); }; // ----- 클래스 ----- const rootCls = __stfJoin( "tds-tf", "tds-tf--split", label ? "tds-tf--shown" : "", isFocused ? "tds-tf--focused" : "", hasError ? "tds-tf--error" : "", disabled ? "tds-tf--disabled" : "", className ); const stfCls = __stfJoin( "tds-stf", isFocused ? "tds-stf--focused" : "", hasError ? "tds-stf--error" : "", disabled ? "tds-stf--disabled" : "" ); // ----- first/second props 분해 (패스스루) ----- const firstProps = first || {}; const secondProps = second || {}; // 두 번째 필드 마스킹 표시 — JSX 에서 절대 배치된 div 로 ••• 표시 // input value 는 비워두지 않고 진짜 숫자를 유지 (form submit 시 사용) // 화면에는 마스크 div 가 덮음 (input 은 transparent) const showMaskOverlay = mask && (secondVal && secondVal.length > 0); return (
{label != null && label !== "" ? ( ) : null}
{/* ----- 앞 필드 ----- */}
{/* ----- 구분자 ----- */} {/* ----- 뒤 필드 ----- */}
{/* mask=true 일 때 type=password 가 이미 가리지만, font 가 ••• 로 보이게 추가 보강. 브라우저 따라 password 글리프가 다를 수 있어 시각 통일. */} {showMaskOverlay && false ? ( // 시각적 마스크 덧붙임은 type=password 와 중복되므로 비활성. // 향후 디자인 요구가 생기면 여기서 켜면 됨. ) : null}
{(hasError && handleError) || help ? (

{hasError && handleError ? handleError : help}

) : null}
); }); /* ---------------------------------------------------------------------------- * 루트 SplitTextField — 직접 사용 가능 (firstLen/secondLen 직접 지정 시) * -------------------------------------------------------------------------- */ const SplitTextField = __stfForwardRef(function SplitTextField(props, ref) { return <__SplitBase ref={ref} {...props} />; }); /* ---------------------------------------------------------------------------- * SplitTextField.RRN13 — 13자리 주민번호 전체 * mask 기본 true (뒤 7자리 보안 마스킹) * -------------------------------------------------------------------------- */ const SplitTextFieldRRN13 = __stfForwardRef(function SplitTextFieldRRN13(props, ref) { const { mask, ...rest } = props; return ( <__SplitBase ref={ref} firstLen={6} secondLen={7} mask={mask !== false} {...rest} /> ); }); /* ---------------------------------------------------------------------------- * SplitTextField.RRNFirst7 — 앞 7자리만 (생년월일 + 성별) * mask 기본 false (성별 코드는 평문) * -------------------------------------------------------------------------- */ const SplitTextFieldRRNFirst7 = __stfForwardRef(function SplitTextFieldRRNFirst7(props, ref) { const { mask, ...rest } = props; return ( <__SplitBase ref={ref} firstLen={6} secondLen={1} mask={mask === true} {...rest} /> ); }); // ----- 서브 부착 ----- SplitTextField.RRN13 = SplitTextFieldRRN13; SplitTextField.RRNFirst7 = SplitTextFieldRRNFirst7; // ----- window 등록 ----- window.SplitTextField = SplitTextField; Object.assign(window, { SplitTextField });