File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
08-14
08-14
08-18
08-18
08-14
08-19
08-18
08-18
08-18
08-18
File name
Commit message
Commit date
"use client";
import {
Fragment,
useId,
useRef,
useState,
type ChangeEvent,
type KeyboardEvent,
type ReactNode,
} from "react";
import { FoxFormLabel } from "../fox-form-label";
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">
{[0, 1, 2].map((index) => (
<Fragment key={index}>
{index > 0 && (
<span className="fox-phone-number__separator" aria-hidden="true">
-
</span>
)}
<div className="fox-phone-number__part">
{/* 앞자리는 고를 목록이 있을 때만 셀렉트다. 목록이 비었는데 셀렉트를 그리면
placeholder("010")가 이미 고른 값처럼 보이는데 실제 값은 비어 있어, 뒷자리만
채운 번호가 조용히 만들어진다 — 그때는 나머지 칸과 같은 입력 칸이 된다. */}
{index === 0 && prefixItems.length > 0 ? (
<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"}
/>
) : (
<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 && (
<FoxFormLabel as="span" id={labelId}>
{label}
</FoxFormLabel>
)}
{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>
);
}