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 { useId, useState, type ReactNode } from "react";
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<unknown>;
onVerifySuccess?: () => void;
onVerifyFail?: (error: unknown) => void;
verifyLabel?: string;
label?: string;
/** 헬퍼 메시지. 오류일 때 색이 바뀐다. */
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,
message,
messageIcon,
localPlaceholder = "아이디",
domainPlaceholder = "도메인 선택",
customPlaceholder = "직접입력",
invalid = false,
disabled = false,
hidden = false,
className,
}: FoxEmailProps) {
const autoId = useId();
const [parts, setParts] = useState<Split>(() =>
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 (
<div className={cx("fox-email", className)}>
{label && (
<span className="fox-email__label" id={`${autoId}-label`}>
{label}
</span>
)}
<div className="fox-email__row">
<div className="fox-email__part">
<FoxInput
size="md"
aria-label="이메일 아이디"
aria-describedby={message ? messageId : undefined}
value={parts.local}
onChange={(next) => commit({ ...parts, local: next })}
placeholder={localPlaceholder}
invalid={invalid}
disabled={disabled}
/>
</div>
<span className="fox-email__at" aria-hidden="true">
@
</span>
<div className="fox-email__part">
<FoxSelect
size="md"
options={domainItems}
value={parts.custom ? CUSTOM_DOMAIN : parts.domain}
onValueChange={handleDomainPick}
placeholder={domainPlaceholder}
error={invalid}
disabled={disabled}
/>
</div>
{/* 목록에서 고른 값이 여기에 채워지고 잠긴다. "직접입력"일 때만 풀린다. */}
<div className="fox-email__part">
<FoxInput
size="md"
aria-label="이메일 도메인"
value={parts.domain}
onChange={(next) => commit({ ...parts, domain: next })}
placeholder={customPlaceholder}
readOnly={!parts.custom}
invalid={invalid}
disabled={disabled}
/>
</div>
{onVerify && (
<FoxButton
size="md"
type="primary"
label={verifyLabel}
loading={verifying}
disabled={disabled}
onAction={handleVerify}
/>
)}
</div>
{message && (
<p className="fox-email__message" id={messageId}>
{messageIcon && (
<span className="fox-email__message-icon" aria-hidden="true">
{messageIcon}
</span>
)}
{message}
</p>
)}
</div>
);
}