File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
"use client";
import {
Fragment,
useId,
useRef,
useState,
type ChangeEvent,
type KeyboardEvent,
type ReactNode,
} from "react";
import { cx } from "../../utils";
import { FoxInput, type FoxInputState } from "../fox-input";
import { FoxSelect, type FoxSelectItem } from "../fox-select";
export type FoxPhoneNumberType = "unit" | "combine";
export type FoxPhoneNumberState = FoxInputState;
type Parts = [string, string, string];
type Lengths = [number, number, number];
export interface FoxPhoneNumberProps {
/** `unit`은 상자 셋을 하이픈으로 잇고, `combine`은 상자 하나에 세 칸을 둔다. */
type?: FoxPhoneNumberType;
/**
* 숫자만 담긴 값. 하이픈은 화면에만 있고 값에는 들어가지 않는다 — `"01012345678"`.
* 꾸며진 값을 넣어도 숫자만 남겨 읽는다. 주면 제어 컴포넌트가 된다.
*/
value?: string;
defaultValue?: string;
onChange?: (value: string) => void;
/**
* 앞자리 목록. `unit`에서는 선택 목록이 되고, 두 모양 모두 **밖에서 받은 값을 나눌 때**
* 앞자리 길이를 알아내는 데 쓴다 — 없으면 `maxLengths[0]`만큼을 앞자리로 본다.
* 지역번호 `02`처럼 짧은 앞자리를 쓰면 목록에 넣거나 `maxLengths`를 함께 조정한다.
*/
prefixOptions?: string[];
label?: string;
/** 헬퍼 메시지. 색은 상태를 따라간다. */
message?: string;
/** 헬퍼 메시지 앞 아이콘. `currentColor`로 그려야 상태 색을 따라간다. */
messageIcon?: ReactNode;
placeholders?: Parts;
/** 칸별 최대 자릿수. 자동 이동의 기준이기도 하다. */
maxLengths?: Lengths;
/** 칸을 다 채우면 다음 칸으로 옮기고, 빈 칸에서 지우면 앞칸으로 돌아간다. */
autoAdvance?: boolean;
/** 오류 표시. `aria-invalid`가 되고 테두리·헬퍼 색이 바뀐다. */
invalid?: boolean;
disabled?: boolean;
readOnly?: boolean;
/** 주면 모양이 그 상태로 고정된다. 없으면 포커스·값·비활성으로 브라우저가 판단한다. */
state?: FoxPhoneNumberState;
/** 참이면 렌더하지 않는다(DOM에 남지 않는다). */
hidden?: boolean;
/** 배치 조정용. */
className?: string;
name?: string;
id?: string;
}
const DEFAULT_PLACEHOLDERS: Parts = ["010", "1234", "5678"];
const DEFAULT_MAX_LENGTHS: Lengths = [3, 4, 4];
const SEGMENT_LABELS: Parts = ["전화번호 앞자리", "전화번호 가운데자리", "전화번호 뒷자리"];
/** 숫자가 아닌 글자는 버린다 — 붙여넣기로 들어온 하이픈·공백도 여기서 걸린다. */
function onlyDigits(text: string): string {
return text.replace(/\D/g, "");
}
/**
* 숫자만 담긴 값을 화면의 세 칸으로 나눈다.
*
* 앞자리는 선택 목록 중 가장 긴 일치를 쓴다 — `02`와 `021`이 함께 있어도 흔들리지 않는다.
* 뒷자리는 항상 마지막 네 자리다(시안 기준). 그래서 `010-123-4567`과 `010-1234-5678`이
* 둘 다 맞게 갈린다. 남은 글자가 그보다 짧으면 아직 입력 중이라 보고 가운데에 둔다.
*/
function splitValue(value: string, prefixOptions: string[], maxLengths: Lengths): Parts {
// 밖에서 `010-1234-5678`처럼 꾸며진 값이 와도 받아 준다 — 내보낼 때는 숫자만 남는다.
const digits = onlyDigits(value ?? "");
const matched = prefixOptions
.filter((option) => option && digits.startsWith(option))
.sort((a, b) => b.length - a.length)[0];
const head = matched ?? digits.slice(0, maxLengths[0]);
const rest = digits.slice(head.length);
if (rest.length > maxLengths[2]) {
return [head, rest.slice(0, rest.length - maxLengths[2]), rest.slice(-maxLengths[2])];
}
return [head, rest, ""];
}
/**
* @fox 전화번호 입력. 화면은 세 칸이지만 값은 숫자만 담긴 문자열 하나다.
*
* 화면의 세 칸이 값의 진실 공급원이다 — 입력 도중 `"0101"`처럼 어디까지가 앞자리인지
* 알 수 없는 값이 생겨도 칸이 흔들리지 않는다. 밖에서 `value`를 바꿨을 때만 다시 나눈다.
*
* ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"`
* (또는 개별 파티셜)로 한 번 불러와야 한다.
*/
export function FoxPhoneNumber({
type = "unit",
value,
defaultValue,
onChange,
prefixOptions = [],
label,
message,
messageIcon,
placeholders = DEFAULT_PLACEHOLDERS,
maxLengths = DEFAULT_MAX_LENGTHS,
autoAdvance = true,
invalid = false,
disabled = false,
readOnly = false,
state,
hidden = false,
className,
name,
id,
}: FoxPhoneNumberProps) {
const autoId = useId();
const [parts, setParts] = useState<Parts>(() =>
splitValue(value ?? defaultValue ?? "", prefixOptions, maxLengths)
);
const [seenValue, setSeenValue] = useState(value);
const tailRefs = useRef<(HTMLInputElement | null)[]>([]);
// 밖에서 값을 바꿨을 때만 다시 나눈다. 우리가 방금 올려보낸 값이면 그대로 둔다.
if (value !== undefined && value !== seenValue) {
setSeenValue(value);
if (value !== parts.join("")) {
setParts(splitValue(value, prefixOptions, maxLengths));
}
}
if (hidden) {
return null;
}
const rootId = id ?? autoId;
const labelId = `${rootId}-label`;
const messageId = `${rootId}-message`;
const errored = invalid || state === "error";
const commit = (index: number, next: string) => {
const nextParts = [...parts] as Parts;
nextParts[index] = next;
setParts(nextParts);
const joined = nextParts.join("");
setSeenValue(joined);
onChange?.(joined);
};
const focusSegment = (index: number) => {
tailRefs.current[index]?.focus();
};
// 숫자가 아닌 입력은 아예 들어오지 못한다. 붙여넣기로 하이픈이 섞여 와도 여기서 걸리고,
// 그 뒤에 자릿수를 자르므로 `010-1234`를 한 칸에 붙여도 넘치지 않는다.
const handleSegmentChange = (index: number) => (next: string) => {
const digits = onlyDigits(next).slice(0, maxLengths[index]);
commit(index, digits);
if (autoAdvance && index < 2 && digits.length >= maxLengths[index]) {
focusSegment(index + 1);
}
};
// 빈 칸에서 백스페이스하면 앞칸으로 돌아간다.
const handleSegmentKeyDown = (index: number) => (event: KeyboardEvent<HTMLInputElement>) => {
if (autoAdvance && event.key === "Backspace" && index > 0 && parts[index] === "") {
event.preventDefault();
focusSegment(index - 1);
}
};
const prefixItems: FoxSelectItem[] = prefixOptions.map((option) => ({
value: option,
label: option,
}));
const body =
type === "unit" ? (
<div className="fox-phone-number__row">
<div className="fox-phone-number__part">
<FoxSelect
size="md"
options={prefixItems}
value={parts[0]}
onValueChange={(next) => {
commit(0, next);
if (autoAdvance) {
focusSegment(1);
}
}}
placeholder={placeholders[0]}
error={errored}
disabled={disabled || state === "disabled"}
readOnly={readOnly || state === "view"}
/>
</div>
{[1, 2].map((index) => (
<Fragment key={index}>
<span className="fox-phone-number__separator" aria-hidden="true">
-
</span>
<div className="fox-phone-number__part">
<FoxInput
size="md"
inputMode="numeric"
aria-label={SEGMENT_LABELS[index]}
value={parts[index]}
onChange={handleSegmentChange(index)}
onKeyDown={handleSegmentKeyDown(index)}
placeholder={placeholders[index]}
maxLength={maxLengths[index]}
invalid={invalid}
disabled={disabled}
readOnly={readOnly}
state={state}
ref={(node) => {
tailRefs.current[index] = node;
}}
/>
</div>
</Fragment>
))}
</div>
) : (
<div className="fox-phone-number__box">
{[0, 1, 2].map((index) => (
<Fragment key={index}>
{index > 0 && (
<span className="fox-phone-number__separator" aria-hidden="true">
-
</span>
)}
<input
className="fox-phone-number__segment"
inputMode="numeric"
aria-label={SEGMENT_LABELS[index]}
aria-invalid={errored || undefined}
aria-describedby={message ? messageId : undefined}
value={parts[index]}
placeholder={placeholders[index]}
maxLength={maxLengths[index]}
disabled={disabled}
readOnly={readOnly}
onChange={(event: ChangeEvent<HTMLInputElement>) =>
handleSegmentChange(index)(event.target.value)
}
onKeyDown={handleSegmentKeyDown(index)}
ref={(node) => {
tailRefs.current[index] = node;
}}
/>
</Fragment>
))}
</div>
);
return (
<div
className={cx("fox-phone-number", className)}
data-state={state}
role="group"
aria-labelledby={label ? labelId : undefined}
>
{label && (
<span className="fox-phone-number__label" id={labelId}>
{label}
</span>
)}
{body}
{/* 폼 전송용. 화면의 세 칸은 이름을 갖지 않는다 — 값은 언제나 합쳐진 하나다. */}
{name && <input type="hidden" name={name} value={parts.join("")} />}
{message && (
<p className="fox-phone-number__message" id={messageId}>
{messageIcon && (
<span className="fox-phone-number__message-icon" aria-hidden="true">
{messageIcon}
</span>
)}
{message}
</p>
)}
</div>
);
}