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";
/**
* 주소 검색 결과. 호출부가 어떤 우편번호 서비스를 쓰든 이 모양으로 바꿔서 돌려준다 —
* 그래야 `@fox`가 특정 API에 묶이지 않는다.
*/
export interface FoxAddressSearchResult {
/** 우편번호. 첫 칸에 그대로 들어간다. */
zipCode: string;
/** 도로명 또는 지번 주소. 둘째 칸에 그대로 들어간다. 어느 쪽을 쓸지는 호출부가 정한다. */
address: string;
/**
* 상세주소 칸에 미리 채울 값(건물명 같은 참고항목).
* 넘기지 않으면 사용자가 적어 둔 상세주소를 건드리지 않는다.
*/
detail?: string;
}
/** 주소 한 벌. 화면의 세 칸을 그대로 담는다. */
export interface FoxAddressValue {
zipCode: string;
address: string;
detail: string;
}
export const EMPTY_ADDRESS: FoxAddressValue = { zipCode: "", address: "", detail: "" };
export interface FoxAddressProps {
value?: FoxAddressValue;
defaultValue?: FoxAddressValue;
onChange?: (value: FoxAddressValue) => void;
/**
* 주소 검색. 호출부가 우편번호 서비스를 띄우고 결과를 `FoxAddressSearchResult`로
* 돌려준다. 사용자가 고르지 않고 닫으면 `null`을 돌려주면 되고, 그때는 값이 그대로
* 남는다. throw·reject하면 `onSearchFail`을 부른다.
*/
onSearch: () => Promise<FoxAddressSearchResult | null>;
onSearchSuccess?: (result: FoxAddressSearchResult) => void;
onSearchFail?: (error: unknown) => void;
searchLabel?: string;
label?: string;
/** 헬퍼 메시지. 오류일 때 색이 바뀐다. */
message?: string;
messageIcon?: ReactNode;
zipCodePlaceholder?: string;
addressPlaceholder?: string;
detailPlaceholder?: string;
invalid?: boolean;
disabled?: boolean;
/** 참이면 렌더하지 않는다(DOM에 남지 않는다). */
hidden?: boolean;
/** 배치 조정용. */
className?: string;
}
/**
* @fox 주소 입력. 우편번호와 주소는 검색 결과로만 채워지고, 상세주소만 손으로 적는다.
*
* 어떤 우편번호 서비스를 쓸지는 `@fox`가 알 필요가 없다 — 호출부가 `onSearch`에서 그
* 서비스를 띄우고 결과를 `FoxAddressSearchResult` 모양으로 바꿔서 돌려주면 된다.
*
* ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"`
* (또는 개별 파티셜)로 한 번 불러와야 한다.
*/
export function FoxAddress({
value,
defaultValue,
onChange,
onSearch,
onSearchSuccess,
onSearchFail,
searchLabel = "주소 검색",
label,
message,
messageIcon,
zipCodePlaceholder = "우편번호",
addressPlaceholder = "주소",
detailPlaceholder = "상세주소",
invalid = false,
disabled = false,
hidden = false,
className,
}: FoxAddressProps) {
const autoId = useId();
const [inner, setInner] = useState<FoxAddressValue>(defaultValue ?? EMPTY_ADDRESS);
const [searching, setSearching] = useState(false);
if (hidden) {
return null;
}
const current = value ?? inner;
const messageId = `${autoId}-message`;
const commit = (next: FoxAddressValue) => {
if (value === undefined) {
setInner(next);
}
onChange?.(next);
};
const handleSearch = async () => {
if (searching) {
return;
}
setSearching(true);
try {
const result = await onSearch();
// 취소하면 `null`이 온다 — 그때는 값이 그대로 남는다.
if (result) {
commit({
zipCode: result.zipCode,
address: result.address,
detail: result.detail ?? current.detail,
});
onSearchSuccess?.(result);
}
} catch (error) {
onSearchFail?.(error);
} finally {
setSearching(false);
}
};
return (
<div className={cx("fox-address", className)}>
{label && (
<span className="fox-address__label" id={`${autoId}-label`}>
{label}
</span>
)}
<div className="fox-address__row">
<div className="fox-address__zip">
{/* 검색 결과로만 채워진다 — 손으로 고칠 수 없다. */}
<FoxInput
size="md"
aria-label={zipCodePlaceholder}
aria-describedby={message ? messageId : undefined}
value={current.zipCode}
placeholder={zipCodePlaceholder}
readOnly
invalid={invalid}
disabled={disabled}
/>
</div>
<FoxButton
size="md"
type="secondary"
label={searchLabel}
loading={searching}
disabled={disabled}
onAction={handleSearch}
/>
</div>
<FoxInput
size="md"
aria-label={addressPlaceholder}
value={current.address}
placeholder={addressPlaceholder}
readOnly
invalid={invalid}
disabled={disabled}
/>
<FoxInput
size="md"
aria-label={detailPlaceholder}
value={current.detail}
onChange={(next) => commit({ ...current, detail: next })}
placeholder={detailPlaceholder}
invalid={invalid}
disabled={disabled}
/>
{message && (
<p className="fox-address__message" id={messageId}>
{messageIcon && (
<span className="fox-address__message-icon" aria-hidden="true">
{messageIcon}
</span>
)}
{message}
</p>
)}
</div>
);
}