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 { useId, useState, type ReactNode } from "react";
import { FoxFormLabel } 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<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)}
role="group"
aria-labelledby={label ? `${autoId}-label` : undefined}
>
{label && (
<FoxFormLabel as="span" id={`${autoId}-label`}>
{label}
</FoxFormLabel>
)}
<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>
);
}