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-18
08-14
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";
/**
* 주소 검색 결과. 호출부가 어떤 우편번호 서비스를 쓰든 이 모양으로 바꿔서 돌려준다 —
* 그래야 `@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)}
role="group"
aria-labelledby={label ? `${autoId}-label` : undefined}
>
{label && (
<FoxFormLabel as="span" id={`${autoId}-label`}>
{label}
</FoxFormLabel>
)}
<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>
);
}