"use client"; import { useId, useState, type ReactNode } from "react"; import { FoxFormLabel, type FoxFormLabelRequirement } from "../fox-form-label"; import { cx } from "../../utils"; import { FoxButton } from "../fox-button"; import { FoxInput } from "../fox-input"; import { FoxSelect, type FoxSelectItem } from "../fox-select"; /** 드롭다운의 "직접입력" 항목이 쓰는 값. 실제 도메인과 겹치지 않게 잡았다. */ const CUSTOM_DOMAIN = "__fox-email-custom__"; export interface FoxEmailProps { /** `아이디@도메인` 전체 값. 둘 다 비어 있으면 빈 문자열이다. */ value?: string; defaultValue?: string; onChange?: (value: string) => void; /** 도메인 드롭다운 목록. "직접입력" 항목은 컴포넌트가 맨 아래에 더한다. */ domainOptions?: string[]; customDomainLabel?: string; /** * 주면 확인 버튼이 생기고, 넘기지 않으면 버튼 자체가 렌더되지 않는다. * resolve하면 성공, throw·reject하면 실패로 본다. */ onVerify?: (email: string) => Promise; onVerifySuccess?: () => void; onVerifyFail?: (error: unknown) => void; verifyLabel?: string; label?: string; /** 라벨 뒤 필수·선택 표시. `label`이 없으면 의미 없다(`FoxInput`·`FoxSelect`와 같은 규약). */ requirement?: FoxFormLabelRequirement; /** 헬퍼 메시지. 오류일 때 색이 바뀐다. */ message?: string; messageIcon?: ReactNode; localPlaceholder?: string; domainPlaceholder?: string; customPlaceholder?: string; invalid?: boolean; disabled?: boolean; /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */ hidden?: boolean; /** 배치 조정용. */ className?: string; } type Split = { local: string; domain: string; custom: boolean }; /** * `아이디@도메인`을 칸으로 나눈다. `@`는 마지막 것을 기준으로 자른다. * * 나눈 도메인이 목록에 없으면 직접 적은 것으로 보고 드롭다운을 "직접입력"에 맞춘다. */ function splitEmail(value: string, domainOptions: string[]): Split { const at = (value ?? "").lastIndexOf("@"); if (at < 0) { return { local: value ?? "", domain: "", custom: false }; } const domain = value.slice(at + 1); return { local: value.slice(0, at), domain, custom: domain !== "" && !domainOptions.includes(domain), }; } function joinEmail(local: string, domain: string): string { return local || domain ? `${local}@${domain}` : ""; } /** * @fox 이메일 입력. 아이디와 도메인을 나눠 받되 값은 합쳐진 문자열 하나다. * * 도메인은 드롭다운에서 고르면 옆 칸에 그 값이 채워지고 읽기 전용이 된다. "직접입력"을 * 고르면 그 칸이 풀려 손으로 적을 수 있다. * * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` * (또는 개별 파티셜)로 한 번 불러와야 한다. */ export function FoxEmail({ value, defaultValue, onChange, domainOptions = [], customDomainLabel = "직접입력", onVerify, onVerifySuccess, onVerifyFail, verifyLabel = "확인", label, requirement, message, messageIcon, localPlaceholder = "아이디", domainPlaceholder = "도메인 선택", customPlaceholder = "직접입력", invalid = false, disabled = false, hidden = false, className, }: FoxEmailProps) { const autoId = useId(); const [parts, setParts] = useState(() => splitEmail(value ?? defaultValue ?? "", domainOptions) ); const [seenValue, setSeenValue] = useState(value); const [verifying, setVerifying] = useState(false); // 밖에서 값을 바꿨을 때만 다시 나눈다. 우리가 방금 올려보낸 값이면 그대로 둔다. if (value !== undefined && value !== seenValue) { setSeenValue(value); if (value !== joinEmail(parts.local, parts.domain)) { setParts(splitEmail(value, domainOptions)); } } if (hidden) { return null; } const messageId = `${autoId}-message`; const email = joinEmail(parts.local, parts.domain); const commit = (next: Split) => { setParts(next); const joined = joinEmail(next.local, next.domain); setSeenValue(joined); onChange?.(joined); }; const domainItems: FoxSelectItem[] = [ ...domainOptions.map((option) => ({ value: option, label: option })), { value: CUSTOM_DOMAIN, label: customDomainLabel }, ]; // 목록에서 고르면 옆 칸을 그 값으로 덮고 잠근다. "직접입력"이면 잠금만 푼다. const handleDomainPick = (picked: string) => { if (picked === CUSTOM_DOMAIN) { commit({ ...parts, custom: true }); return; } commit({ ...parts, domain: picked, custom: false }); }; const handleVerify = async () => { if (!onVerify || verifying) { return; } setVerifying(true); try { await onVerify(email); onVerifySuccess?.(); } catch (error) { onVerifyFail?.(error); } finally { setVerifying(false); } }; return (
{label && ( {label} )} {/* 시안의 form-email 알맹이 — form-group / select / btn이 4px 간격으로 쌓인다. 라벨·헬퍼와의 간격은 바깥(`field.root`)이 갖는다. */}
{/* 시안의 form-group — 아이디·@·도메인이 한 줄이다. 도메인 목록은 아래 줄에 따로 선다. */}
commit({ ...parts, local: next })} placeholder={localPlaceholder} invalid={invalid} disabled={disabled} />
{/* 목록에서 고른 값이 여기에 채워지고 잠긴다. "직접입력"일 때만 풀린다. */}
commit({ ...parts, domain: next })} placeholder={customPlaceholder} readOnly={!parts.custom} invalid={invalid} disabled={disabled} />
{onVerify && ( )}
{message && (

{messageIcon && ( )} {message}

)}
); }