Japanese Postal Code to Address Autofill That Never Wipes the Street Number (React)
A React address form that autofills prefecture, city and town from a Japanese postal code without wiping the street number you already typed. The same input is replayed through a typical implementation (overwrite with the first match), and a strip above the form shows the result side by side — “this form: street number kept / typical: street number wiped”. Only empty fields are filled; typed fields get a suggested replacement and an undo. Scenes for typing during a lookup, out-of-order responses and multiple matches (pick with the arrow keys) open from a ?case= URL. Full-width digits, hyphens and 〒 are accepted. Swap in your own API through a single lookup(zip, signal). No dependencies.
Preview
BLK-106フォーム・住所の自動入力
郵便番号で住所を入れても、入力済みの番地は消さない
場面
この場面を再生すると
この実装番地「2-3-4」を保持
よくある実装番地「2-3-4」が消去
大阪の住所まで入れた後で、郵便番号を名古屋の番号に打ち直します。
試せる番号件数付きは候補が複数
応答の設定(遅れ 0.3秒)
自社のフォームへ組み込む
データの取り出し口は lookup(zip, signal) の1つだけです。7桁にそろえた番号と中断用の signal を受け取り、候補の配列を返せば、空いている欄だけを埋める処理・候補の選択・元に戻すがそのまま使えます。
import AddressAutofill, { type AddressLookup } from "@/components/jp-address-autofill";
// 自社の住所検索 API につなぐ例。signal で前の検索を中断する
const lookup: AddressLookup = async (zip, signal) => {
const res = await fetch(`/api/postal/${zip}`, { signal });
if (!res.ok) throw new Error(String(res.status));
return (await res.json()) as { pref: string; city: string; town: string }[];
};
export default function ShippingForm() {
return <AddressAutofill lookup={lookup} />;
}Read the full description
A React address form that autofills prefecture, city and town from a Japanese postal code without wiping the street number you already typed. The same input is replayed through a typical implementation (overwrite with the first match), and a strip above the form shows the result side by side — “this form: street number kept / typical: street number wiped”. Only empty fields are filled; typed fields get a suggested replacement and an undo. Scenes for typing during a lookup, out-of-order responses and multiple matches (pick with the arrow keys) open from a ?case= URL. Full-width digits, hyphens and 〒 are accepted. Swap in your own API through a single lookup(zip, signal). No dependencies.
- Added:
- 2026-10-28
- Dependencies:
- None
- tags
- #block #form #address #postal-code #autofill #autocomplete #combobox #listbox #abort-controller #race-condition #undo #japanese #accessibility #keyboard #reduced-motion #hairline #no-dependency
"use client";
import {
useCallback,
useEffect,
useId,
useRef,
useState,
useSyncExternalStore,
type CompositionEvent,
type KeyboardEvent,
type ReactNode,
} from "react";
/**
* 番地を消さない郵便番号→住所入力(よくある実装と並べて比べる)
*
* 差別化: 既存のフォーム系(contact-form-block・whitepaper-download-cta)は欄の並びと見た目の見本で、入力中の状態の扱いは持たない。
* 本ブロックは郵便番号の自動入力という1操作の「状態の正しさ」が主題で、取得中の編集・応答の逆転・複数候補・元に戻すまでを実装し、
* 同じ操作をよくある実装で行った結果を、フォームの上に貼り付く2列の要約(この実装/よくある実装)と右の補助欄の表で並べる。
* 英字の小見出し・見出しの「。」止め・末尾の矢印リンク・角丸は使わない。
*
* 汎用技法メモ:
* 1. **空いている欄にだけ入れる**。入力済みの欄は書き換えず、欄の下へ差し替え候補を出す。町名・番地の欄は、今の値の先頭の数字から後ろ
* (番地)を残したまま町名だけを差し替えた値を候補にする。
* 2. **検索ごとに番号と「取得中に手で直した欄」を持つ**。応答が届いたとき、番号が最新でなければ捨て、手で直した欄には入れない。
* 前の検索は AbortController で中断する(データの取り出し口は lookup(zip, signal) の1つだけ)。
* 3. **フォーカスを移すのは郵便番号欄にフォーカスが残っているときだけ**。取得中に別の欄へ移った人のカーソルは動かさない。
* 4. **元に戻すは全欄を戻す**。入れる直前の都道府県・市区町村・町名と番地・建物名を丸ごと控えておく。
* 5. **番号は先にそろえる**。NFKC で全角数字を半角にし、〒・ハイフン類・空白を除いてから7桁かどうかを見る。IME の変換中は判定しない。
* 6. **結果は押したボタンのそばで言う**。「この住所にする」の後の「元に戻す」はその欄の下に出し、変わった欄は背景色を短く戻して示す。
*/
export type AddressCandidate = { pref: string; city: string; town: string };
export type AddressLookup = (zip: string, signal: AbortSignal) => Promise<AddressCandidate[]>;
type FieldName = "pref" | "city" | "line1" | "line2";
type Fields = { zip: string; pref: string; city: string; line1: string; line2: string };
type CaseId = "retype" | "typing" | "race" | "multi";
const AMBER = "#d97706";
const EMPTY: Fields = { zip: "", pref: "", city: "", line1: "", line2: "" };
const PAGE_URL = "https://designs.first-ch.com/d/jp-address-autofill";
const PREFS = [
"北海道", "青森県", "岩手県", "宮城県", "秋田県", "山形県", "福島県", "茨城県", "栃木県", "群馬県", "埼玉県", "千葉県",
"東京都", "神奈川県", "新潟県", "富山県", "石川県", "福井県", "山梨県", "長野県", "岐阜県", "静岡県", "愛知県", "三重県",
"滋賀県", "京都府", "大阪府", "兵庫県", "奈良県", "和歌山県", "鳥取県", "島根県", "岡山県", "広島県", "山口県", "徳島県",
"香川県", "愛媛県", "高知県", "福岡県", "佐賀県", "長崎県", "熊本県", "大分県", "宮崎県", "鹿児島県", "沖縄県",
];
const k = (town: string, ...streets: string[]) => streets.map((s) => `${s}${town}`);
/** デモ用の同梱データ(実在の郵便番号10件だけ) */
const DEMO_DATA: Record<string, AddressCandidate[]> = {
"1000001": [{ pref: "東京都", city: "千代田区", town: "千代田" }],
"1500002": [{ pref: "東京都", city: "渋谷区", town: "渋谷" }],
"5300001": [{ pref: "大阪府", city: "大阪市北区", town: "梅田" }],
"4600008": [{ pref: "愛知県", city: "名古屋市中区", town: "栄" }],
"9800021": [{ pref: "宮城県", city: "仙台市青葉区", town: "中央" }],
"8100001": [{ pref: "福岡県", city: "福岡市中央区", town: "天神" }],
"6050074": [{ pref: "京都府", city: "京都市東山区", town: "祇園町南側" }],
"0790177": ["上美唄町協和", "上美唄町南"].map((town) => ({ pref: "北海道", city: "美唄市", town })),
"6048006": k(
"下丸屋町",
"御池通河原町東入",
"河原町通御池下る",
"河原町通姉小路上る",
"御池通木屋町西入",
"木屋町通御池下る",
"木屋町通姉小路上る",
).map((town) => ({ pref: "京都府", city: "京都市中京区", town })),
"6040981": k(
"毘沙門町",
"御幸町通丸太町下る",
"御幸町通竹屋町上る",
"丸太町通御幸町西入",
"丸太町通御幸町東入",
"竹屋町通御幸町西入",
"竹屋町通御幸町東入",
"竹屋町通麩屋町東入",
).map((town) => ({ pref: "京都府", city: "京都市中京区", town })),
};
/** 試せる番号(横1列に並べるので、場面で使う番号と候補が複数の番号を先に置く) */
const CHIPS = ["5300001", "4600008", "6048006", "0790177", "1000001", "1500002", "6040981", "9800021", "8100001", "6050074"];
const CASES: { id: CaseId; label: string; lead: string; delay: number; start: Fields; expect: Pair }[] = [
{
id: "retype",
label: "自動入力後の打ち直し",
lead: "大阪の住所まで入れた後で、郵便番号を名古屋の番号に打ち直します。",
delay: 300,
start: { zip: "530-0001", pref: "大阪府", city: "大阪市北区", line1: "梅田2-3-4", line2: "見本ビル 5階" },
expect: { ours: "番地「2-3-4」を保持", theirs: "番地「2-3-4」が消去" },
},
{
id: "typing",
label: "取得中の番地入力",
lead: "応答が1.5秒かかる間に、先に番地「1-1」を打ちます。",
delay: 1500,
start: EMPTY,
expect: { ours: "番地「1-1」を保持", theirs: "番地「1-1」が消去" },
},
{
id: "race",
label: "応答の逆転",
lead: "530-0001 を入れてすぐ 460-0008 に直します。530-0001 の応答だけ2.5秒遅れて届きます。",
delay: 300,
start: EMPTY,
expect: { ours: "後の番号の住所のまま", theirs: "遅れた応答で大阪に戻る" },
},
{
id: "multi",
label: "複数候補",
lead: "候補が6件ある 604-8006 で、3件目を選んで番地「12」を打ちます。",
delay: 300,
start: EMPTY,
expect: { ours: "選んだ3件目の町名", theirs: "1件目の町名で確定" },
},
];
const CASE_IDS = CASES.map((c) => c.id);
const isCase = (v: string | null): v is CaseId => !!v && (CASE_IDS as string[]).includes(v);
/** 全角数字・ハイフン・〒・空白をそろえて数字だけにする */
export function normalizeZip(raw: string) {
return raw.normalize("NFKC").replace(/[〒\s\-‐‑‒–—―−ーー]/g, "");
}
const formatZip = (d: string) => (d.length === 7 ? `${d.slice(0, 3)}-${d.slice(3)}` : d);
/** 町名・番地の欄から番地(最初の数字から後ろ)を取り出す */
const tailOf = (line1: string) => line1.match(/[0-90-9].*$/)?.[0] ?? "";
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
function makeDemoLookup(getDelay: (zip: string) => number, getFail: () => boolean): AddressLookup {
return (zip, signal) =>
new Promise((resolve, reject) => {
const t = window.setTimeout(() => {
if (getFail()) reject(new Error("network"));
else resolve(DEMO_DATA[zip] ?? []);
}, getDelay(zip));
signal.addEventListener("abort", () => {
window.clearTimeout(t);
reject(new DOMException("aborted", "AbortError"));
});
});
}
const LABEL: Record<FieldName, string> = { pref: "都道府県", city: "市区町村", line1: "町名・番地", line2: "建物名" };
type Naive = { f: Fields; appliedZip: string; count: number; stole: boolean; touched: boolean };
/** 比較の要約(この実装/よくある実装を短く1語ずつ) */
type Pair = { ours: string; theirs: string };
/** よくある実装との差を、1行の文(text)と短い対(ours / theirs)で言う */
function diffLine(g: Fields, n: Naive, listOpen: boolean): { text: string; hit: boolean } & Pair {
if (!n.touched) return { text: "まだ差はありません", hit: false, ours: "", theirs: "" };
const tail = tailOf(g.line1);
if (tail && !n.f.line1.includes(tail)) {
return { text: `よくある実装では番地「${tail}」が消えた`, hit: true, ours: `番地「${tail}」を保持`, theirs: `番地「${tail}」が消去` };
}
if (g.line2 && n.f.line2 !== g.line2) {
return { text: `よくある実装では建物名「${g.line2}」が消えた`, hit: true, ours: `建物名「${g.line2}」を保持`, theirs: "建物名が消去" };
}
const zip = normalizeZip(g.zip);
if (n.appliedZip && zip.length === 7 && n.appliedZip !== zip) {
const z = formatZip(zip);
if (n.f.city !== g.city) {
return { text: `よくある実装では市区町村が「${n.f.city}」になり、郵便番号 ${z} と合わない`, hit: true, ours: `${z} の「${g.city || "空欄"}」`, theirs: `古い応答の「${n.f.city}」` };
}
if (n.f.line1 !== g.line1) {
return { text: `よくある実装では町名が「${n.f.line1}」になり、郵便番号 ${z} と合わない`, hit: true, ours: `${z} の「${g.line1 || "空欄"}」`, theirs: `古い応答の「${n.f.line1}」` };
}
}
if (n.count > 1) {
if (listOpen || !g.line1) {
return { text: `よくある実装は${n.count}件の候補を確かめずに、1件目「${n.f.line1}」を入れた`, hit: true, ours: `${n.count}件から選ぶ`, theirs: "1件目を自動で入力" };
}
const town = (v: string) => v.slice(0, v.length - tailOf(v).length);
if (town(g.line1) !== town(n.f.line1)) {
return { text: `よくある実装では町名が1件目の「${town(n.f.line1)}」になった(選んだのは「${town(g.line1)}」)`, hit: true, ours: `選んだ「${town(g.line1)}」`, theirs: `1件目「${town(n.f.line1)}」` };
}
}
for (const key of ["pref", "city", "line1"] as const) {
if (n.f[key] !== g[key]) {
return { text: `よくある実装では${LABEL[key]}が「${n.f[key] || "空欄"}」になった(こちらは「${g[key] || "空欄"}」)`, hit: true, ours: `${LABEL[key]}「${g[key] || "空欄"}」`, theirs: `${LABEL[key]}「${n.f[key] || "空欄"}」` };
}
}
if (n.stole) return { text: "よくある実装では、入力中の欄から町名・番地の欄へカーソルが移った", hit: true, ours: "カーソルはそのまま", theirs: "カーソルが町名欄へ移動" };
return { text: "この操作では、どちらも同じ結果です", hit: false, ours: "同じ結果", theirs: "同じ結果" };
}
const noopSubscribe = () => () => {};
const readCase = () => new URLSearchParams(window.location.search).get("case");
const serverNull = () => null;
export type AddressAutofillProps = {
/** 住所の取り出し口。省略時は同梱の10件で動くデモ用の検索 */
lookup?: AddressLookup;
/** 最初に開く場面。URL の ?case= があればそちらを優先 */
initialCase?: CaseId;
headingTag?: "h1" | "h2" | "p";
};
export default function AddressAutofill({ lookup, initialCase = "retype", headingTag }: AddressAutofillProps) {
const Heading = headingTag ?? "h2";
const uid = useId().replace(/[^a-zA-Z0-9_-]/g, "");
const urlCase = useSyncExternalStore(noopSubscribe, readCase, serverNull);
const [picked, setPicked] = useState<CaseId | null>(null);
const caseId: CaseId = picked ?? (isCase(urlCase) ? urlCase : initialCase);
const pick = (id: CaseId) => {
setPicked(id);
const url = new URL(window.location.href);
url.searchParams.set("case", id);
window.history.replaceState(window.history.state, "", url);
};
const onTabKey = (e: KeyboardEvent<HTMLButtonElement>, i: number) => {
const d = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0;
if (!d) return;
e.preventDefault();
const next = CASES[(i + d + CASES.length) % CASES.length].id;
pick(next);
document.getElementById(`${uid}-tab-${next}`)?.focus();
};
return (
<section
className="@container mx-auto w-full max-w-6xl border border-stone-200 bg-white text-base text-stone-900"
aria-labelledby={`${uid}-title`}
>
<div className="px-5 py-5 @2xl:px-8">
<p className="font-display text-xs font-semibold tracking-[0.18em] text-stone-500">
BLK-106<span className="mx-2 inline-block h-px w-5 bg-stone-300 align-middle" />
<span className="font-sans tracking-normal">フォーム・住所の自動入力</span>
</p>
<Heading
id={`${uid}-title`}
className="mt-2 max-w-3xl text-[clamp(1.2rem,2.6vw,1.6rem)] font-bold leading-[1.45] tracking-tight text-balance [word-break:auto-phrase]"
>
郵便番号で住所を入れても、入力済みの番地は消さない
</Heading>
</div>
<div className="border-t border-stone-200 px-5 py-3 @2xl:px-8">
<p id={`${uid}-cases`} className="sr-only">
場面
</p>
<div
role="tablist"
aria-labelledby={`${uid}-cases`}
className="grid grid-cols-2 border-l border-t border-stone-200 @2xl:grid-cols-4"
>
{CASES.map((c, i) => {
const on = c.id === caseId;
return (
<button
key={c.id}
id={`${uid}-tab-${c.id}`}
type="button"
role="tab"
aria-selected={on}
aria-controls={`${uid}-panel`}
tabIndex={on ? 0 : -1}
data-case={c.id}
onClick={() => pick(c.id)}
onKeyDown={(e) => onTabKey(e, i)}
className={`relative min-h-12 border-b border-r border-stone-200 px-3 py-2 text-left text-[15px] font-bold leading-snug outline-none transition-colors focus-visible:z-10 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#d97706] motion-reduce:transition-none ${
on ? "bg-amber-50 text-stone-900" : "bg-white text-stone-500 hover:text-stone-900"
}`}
>
<span className="font-display mr-2 text-xs tabular-nums text-stone-400">{i + 1}</span>
{c.label}
<span
aria-hidden="true"
className={`absolute inset-x-0 bottom-0 h-0.5 origin-left bg-[#d97706] transition-transform duration-200 motion-reduce:transition-none ${on ? "scale-x-100" : "scale-x-0"}`}
/>
</button>
);
})}
</div>
</div>
<div id={`${uid}-panel`} role="tabpanel" aria-labelledby={`${uid}-tab-${caseId}`}>
<Demo key={caseId} caseId={caseId} lookup={lookup} uid={uid} />
</div>
<Embed uid={uid} />
</section>
);
}
function Demo({ caseId, lookup, uid }: { caseId: CaseId; lookup?: AddressLookup; uid: string }) {
const scene = CASES.find((c) => c.id === caseId) ?? CASES[0];
const [form, setFormState] = useState<Fields>(scene.start);
const [naive, setNaiveState] = useState<Naive>({ f: scene.start, appliedZip: "", count: 0, stole: false, touched: false });
const [delay, setDelay] = useState(scene.delay);
const [fail, setFail] = useState(false);
const [searching, setSearching] = useState(false);
const [status, setStatus] = useState<{ tone: "info" | "warn"; text: string }>({ tone: "info", text: "" });
const [list, setList] = useState<{ items: AddressCandidate[]; active: number } | null>(null);
const [suggest, setSuggest] = useState<Partial<Record<FieldName, string>> | null>(null);
const [undo, setUndo] = useState<{ prev: Fields; at: FieldName } | null>(null);
const [playing, setPlaying] = useState(false);
const [copied, setCopied] = useState(false);
const [note, setNote] = useState("");
const [flash, setFlash] = useState<{ keys: string[]; n: number } | null>(null);
const formRef = useRef(form);
const naiveRef = useRef(naive);
const listRef = useRef(list);
const delayRef = useRef(delay);
const failRef = useRef(fail);
const seqRef = useRef(0);
const pendingRef = useRef<{ seq: number; zip: string; dirty: Set<FieldName>; ctrl: AbortController; timer: number } | null>(
null,
);
const lastZipRef = useRef(normalizeZip(scene.start.zip));
const composingRef = useRef(false);
const playRef = useRef(0);
const flashTimerRef = useRef(0);
const zipRef = useRef<HTMLInputElement>(null);
const line1Ref = useRef<HTMLInputElement>(null);
const setForm = (f: Fields) => {
formRef.current = f;
setFormState(f);
};
const setNaive = (n: Naive) => {
naiveRef.current = n;
setNaiveState(n);
};
const openList = (l: { items: AddressCandidate[]; active: number } | null) => {
listRef.current = l;
setList(l);
};
// 場面を離れたら、取得中の検索と再生を止める
useEffect(
() => () => {
playRef.current++;
window.clearTimeout(flashTimerRef.current);
const p = pendingRef.current;
if (p) {
p.ctrl.abort();
window.clearTimeout(p.timer);
}
},
[],
);
const getLookup = useCallback((): AddressLookup => {
if (lookup) return lookup;
return makeDemoLookup(
(zip) => (caseId === "race" && zip === "5300001" ? 2500 : delayRef.current),
() => failRef.current,
);
}, [lookup, caseId]);
// 変わった欄を短く色で知らせる(keys は欄の名前、候補の行は "hint-欄の名前")
const flashKeys = (keys: string[]) => {
if (!keys.length) return;
window.clearTimeout(flashTimerRef.current);
setFlash((f) => ({ keys, n: (f?.n ?? 0) + 1 }));
flashTimerRef.current = window.setTimeout(() => setFlash(null), 1400);
};
const flashCls = (key: string) => (flash?.keys.includes(key) ? (flash.n % 2 ? "jpaa-flash-a" : "jpaa-flash-b") : "");
const focusLine1 = () => {
const el = line1Ref.current;
if (!el) return;
el.focus();
const end = el.value.length;
el.setSelectionRange(end, end);
};
const applyCandidate = (c: AddressCandidate, dirty: Set<FieldName>) => {
const cur = formRef.current;
const fills: Partial<Fields> = {};
const conflicts: Partial<Record<FieldName, string>> = {};
if (!cur.pref && !dirty.has("pref")) fills.pref = c.pref;
else if (cur.pref !== c.pref) conflicts.pref = c.pref;
if (!cur.city && !dirty.has("city")) fills.city = c.city;
else if (cur.city !== c.city) conflicts.city = c.city;
if (!cur.line1 && !dirty.has("line1")) fills.line1 = c.town;
else if (!cur.line1.startsWith(c.town)) conflicts.line1 = `${c.town}${tailOf(cur.line1)}`;
const filled = Object.keys(fills).length > 0;
const conflicted = Object.keys(conflicts).length > 0;
const filledKeys = Object.keys(fills) as FieldName[];
if (filled) {
setUndo({ prev: cur, at: filledKeys[filledKeys.length - 1] });
setForm({ ...cur, ...fills });
} else setUndo(null);
setSuggest(conflicted ? conflicts : null);
flashKeys([...filledKeys, ...Object.keys(conflicts).map((n) => `hint-${n}`)]);
const names = filledKeys.map((n) => (n === "line1" ? "町名" : LABEL[n])).join("・");
if (filled && conflicted) setStatus({ tone: "info", text: `${names}を入れました。入力済みの欄は変えず、下に候補を出しています` });
else if (filled) setStatus({ tone: "info", text: `${names}を入れました。続けて番地を入力してください` });
else if (conflicted) setStatus({ tone: "info", text: "入力済みの欄は変えていません。下の候補で差し替えられます" });
else setStatus({ tone: "info", text: "入力済みの住所と一致しています" });
if (document.activeElement === zipRef.current) focusLine1();
};
const naiveRespond = (zip: string, items: AddressCandidate[]) => {
if (!items.length) return;
const c = items[0];
const active = document.activeElement;
const n = naiveRef.current;
setNaive({
f: { ...n.f, pref: c.pref, city: c.city, line1: c.town },
appliedZip: zip,
count: items.length,
stole: active !== zipRef.current && active !== line1Ref.current,
touched: true,
});
};
const cancelPending = () => {
const p = pendingRef.current;
if (!p) return;
p.ctrl.abort();
window.clearTimeout(p.timer);
pendingRef.current = null;
setSearching(false);
};
const startLookup = (zip: string) => {
cancelPending();
openList(null);
setSuggest(null);
setUndo(null);
const seq = ++seqRef.current;
const ctrl = new AbortController();
const timer = window.setTimeout(() => {
if (seqRef.current !== seq) return;
setSearching(true);
setStatus({ tone: "info", text: "検索中です" });
}, 400);
const pending = { seq, zip, dirty: new Set<FieldName>(), ctrl, timer };
pendingRef.current = pending;
setStatus({ tone: "info", text: "" });
const run = getLookup();
run(zip, ctrl.signal).then(
(items) => {
if (seqRef.current !== seq || ctrl.signal.aborted) return;
window.clearTimeout(timer);
pendingRef.current = null;
setSearching(false);
if (!items.length) {
setStatus({ tone: "warn", text: `${formatZip(zip)} はこのデモの対象外です。上の10件の番号で試せます` });
} else if (items.length === 1) {
applyCandidate(items[0], pending.dirty);
} else {
openList({ items, active: 0 });
setStatus({ tone: "info", text: `候補が${items.length}件あります。上下の矢印キーで選び、Enter で決めてください` });
}
},
() => {
if (seqRef.current !== seq || ctrl.signal.aborted) return;
window.clearTimeout(timer);
pendingRef.current = null;
setSearching(false);
setStatus({ tone: "warn", text: "取得できませんでした。そのまま手で入力できます" });
},
);
// よくある実装: 前の検索を止めず、届いた順に1件目で上書きする
run(zip, new AbortController().signal).then(
(items) => naiveRespond(zip, items),
() => {},
);
};
const processZip = (raw: string) => {
const d = normalizeZip(raw);
if (d.length === 7 && /^\d{7}$/.test(d)) {
if (d === lastZipRef.current && (pendingRef.current || listRef.current)) return;
lastZipRef.current = d;
const shown = formatZip(d);
if (formRef.current.zip !== shown) {
setForm({ ...formRef.current, zip: shown });
setNaive({ ...naiveRef.current, f: { ...naiveRef.current.f, zip: shown } });
}
startLookup(d);
} else {
lastZipRef.current = d;
cancelPending();
openList(null);
}
};
const onZip = (value: string) => {
setForm({ ...formRef.current, zip: value });
setNaive({ ...naiveRef.current, f: { ...naiveRef.current.f, zip: value } });
if (!composingRef.current) processZip(value);
};
const onField = (name: FieldName, value: string) => {
// よくある実装の側にも同じ打鍵を足す・消す(値の丸写しにすると、上書きで生じた差が消えてしまう)
const old = formRef.current[name];
const nv = naiveRef.current.f[name];
const mirrored = value.startsWith(old)
? nv + value.slice(old.length)
: old.startsWith(value)
? nv.slice(0, Math.max(0, nv.length - (old.length - value.length)))
: value;
setForm({ ...formRef.current, [name]: value });
setNaive({ ...naiveRef.current, f: { ...naiveRef.current.f, [name]: mirrored } });
pendingRef.current?.dirty.add(name);
setUndo(null);
};
const choose = (i: number) => {
const l = listRef.current;
if (!l) return;
openList(null);
applyCandidate(l.items[i], new Set());
};
const onZipKey = (e: KeyboardEvent<HTMLInputElement>) => {
const l = listRef.current;
if (!l) return;
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
const d = e.key === "ArrowDown" ? 1 : -1;
openList({ ...l, active: (l.active + d + l.items.length) % l.items.length });
} else if (e.key === "Enter") {
e.preventDefault();
choose(l.active);
} else if (e.key === "Escape") {
e.preventDefault();
openList(null);
setStatus({ tone: "info", text: "候補を閉じました。手で入力できます" });
}
};
const applySuggest = () => {
if (!suggest) return;
const cur = formRef.current;
const keys = (["pref", "city", "line1"] as const).filter((n) => suggest[n] !== undefined);
setUndo({ prev: cur, at: keys[keys.length - 1] });
setForm({ ...cur, ...suggest });
setSuggest(null);
flashKeys([...keys]);
setStatus({ tone: "info", text: "候補の住所に差し替えました。元に戻すこともできます" });
};
const doUndo = () => {
if (!undo) return;
const { prev } = undo;
const cur = formRef.current;
flashKeys((["pref", "city", "line1", "line2"] as const).filter((n) => cur[n] !== prev[n]));
setForm({ ...cur, pref: prev.pref, city: prev.city, line1: prev.line1, line2: prev.line2 });
setUndo(null);
setSuggest(null);
setStatus({ tone: "info", text: "入れる前の値にすべて戻しました" });
};
// 場面の再生(同じ処理を順に呼ぶ)
const play = async () => {
const token = ++playRef.current;
const alive = () => playRef.current === token;
setPlaying(true);
setNote("再生中です");
const typeZip = async (text: string, from = "") => {
zipRef.current?.focus();
onZip(from);
for (const ch of Array.from(text)) {
await sleep(70);
if (!alive()) return false;
onZip(formRef.current.zip + ch);
}
return true;
};
try {
if (caseId === "retype") {
if (!(await typeZip("〒460ー0008"))) return;
await sleep(delayRef.current + 300);
} else if (caseId === "typing") {
if (!(await typeZip("1000001"))) return;
await sleep(250);
line1Ref.current?.focus();
for (const ch of ["1", "-", "1"]) {
await sleep(120);
if (!alive()) return;
onField("line1", formRef.current.line1 + ch);
}
await sleep(delayRef.current + 200);
} else if (caseId === "race") {
if (!(await typeZip("530-0001"))) return;
await sleep(200);
if (!(await typeZip("460-0008"))) return;
await sleep(2700);
} else {
if (!(await typeZip("604-8006"))) return;
await sleep(delayRef.current + 500);
// 位置は直接指定する(一覧がマウスの下に出ると、hover で選択位置が動くため)
for (const i of [1, 2]) {
if (!alive() || !listRef.current) return;
openList({ ...listRef.current, active: i });
await sleep(350);
}
if (!alive() || !listRef.current) return;
choose(2);
for (const ch of ["1", "2"]) {
await sleep(140);
if (!alive()) return;
onField("line1", formRef.current.line1 + ch);
}
}
} finally {
if (alive()) {
setPlaying(false);
setNote(`再生しました。${diffLine(formRef.current, naiveRef.current, !!listRef.current).text}`);
}
}
};
const tryChip = (d: string) => {
zipRef.current?.focus();
lastZipRef.current = "";
onZip(formatZip(d));
};
const copyUrl = async () => {
const url = `${PAGE_URL}?case=${caseId}`;
try {
await navigator.clipboard.writeText(url);
setCopied(true);
setStatus({ tone: "info", text: "この場面の URL をコピーしました" });
} catch {
setCopied(false);
}
};
const diff = diffLine(form, naive, !!list);
// 操作の前は、この場面で起きることを先に見せる
const pair: Pair = naive.touched ? diff : scene.expect;
const pairHit = naive.touched ? diff.hit : true;
const lastConflict = (["pref", "city", "line1"] as const).filter((n) => suggest?.[n] !== undefined).pop();
const listId = `${uid}-list`;
const input =
"block h-12 w-full scroll-mt-44 border border-stone-300 bg-white px-3 text-base text-stone-900 outline-none transition-colors placeholder:text-stone-400 hover:border-stone-400 focus-visible:border-[#d97706] focus-visible:outline-2 focus-visible:outline-offset-0 focus-visible:outline-[#d97706] motion-reduce:transition-none";
const btn =
"inline-flex min-h-11 items-center justify-center border px-4 text-[15px] font-bold outline-none transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#d97706] motion-reduce:transition-none disabled:opacity-50";
// 欄の下: 差し替え候補・「この住所にする」・入れた直後の「元に戻す」(押したボタンのすぐ近くに結果を出す)
const suggestionSlot = (name: FieldName): ReactNode => {
const v = suggest?.[name];
return (
<>
{v !== undefined && (
<p
className={`mt-2 border-l-2 border-[#d97706] pl-3 text-[15px] leading-relaxed text-stone-700 [overflow-wrap:anywhere] ${flashCls(`hint-${name}`)}`}
>
<span className="text-stone-500">候補 </span>
<span className="font-bold text-stone-900">{v}</span>
</p>
)}
{suggest && name === lastConflict && (
<div className="mt-3 flex flex-wrap items-center gap-2">
<button type="button" onClick={applySuggest} className={`${btn} apply border-[#d97706] bg-[#d97706] text-white hover:bg-amber-700`}>
この住所にする
</button>
<button
type="button"
onClick={() => {
setSuggest(null);
setStatus({ tone: "info", text: "今の入力のままにしました" });
}}
className={`${btn} border-stone-300 bg-white text-stone-700 hover:border-stone-500`}
>
今のままにする
</button>
</div>
)}
{undo?.at === name && (
<div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-2">
<p className="text-[15px] text-stone-600">入れました。番地・建物名も含めて戻せます</p>
<button type="button" onClick={doUndo} className={`${btn} undo border-stone-900 bg-white text-stone-900 hover:bg-stone-50`}>
元に戻す
</button>
</div>
)}
</>
);
};
return (
<>
{/* 比較の要約: 操作中の欄から目を離さずに見えるよう上に貼り付ける(既定の top は掲載先のヘッダーの高さ。--sticky-top で変える) */}
<div className="sticky top-[var(--sticky-top,61px)] z-10 min-[641px]:top-[var(--sticky-top,73px)] border-t border-stone-200 bg-white/95 px-5 py-3 backdrop-blur-sm @2xl:px-8">
<div
key={`${pair.ours}|${pair.theirs}`}
className="jpaa-strip grid grid-cols-2 gap-x-4 @2xl:grid-cols-[auto_minmax(0,1fr)_minmax(0,1fr)] @2xl:items-center @2xl:gap-x-6"
aria-live="polite"
>
<p className="col-span-2 text-xs font-bold whitespace-nowrap text-stone-500 @2xl:col-span-1">
{naive.touched ? "同じ操作の結果" : "この場面を再生すると"}
</p>
<p className="mt-1 border-l-2 border-[#d97706] pl-2.5 @2xl:mt-0">
<span className="block text-[13px] text-stone-500">この実装</span>
<span className="diff-ours block text-[15px] font-bold leading-snug text-stone-900 [overflow-wrap:anywhere]">{pair.ours}</span>
</p>
<p className="mt-1 border-l-2 border-stone-300 pl-2.5 @2xl:mt-0">
<span className="block text-[13px] text-stone-500">よくある実装</span>
<span
className={`diff-theirs block text-[15px] font-bold leading-snug [overflow-wrap:anywhere] ${pairHit ? "text-[#b45309]" : "text-stone-600"}`}
>
{pair.theirs}
</span>
</p>
</div>
</div>
<div className="flex flex-col gap-3 border-t border-stone-200 px-5 py-4 @2xl:flex-row @2xl:items-start @2xl:justify-between @2xl:gap-8 @2xl:px-8">
<p className="max-w-2xl text-[15px] leading-[1.8] text-stone-700">{scene.lead}</p>
<div className="flex shrink-0 flex-col gap-2 @2xl:items-end">
<div className="flex flex-wrap gap-2">
<button type="button" onClick={play} disabled={playing} className={`${btn} play border-stone-900 bg-stone-900 text-white hover:bg-stone-700`}>
{playing ? "再生中" : "この場面を再生"}
</button>
<button type="button" onClick={copyUrl} className={`${btn} copy-url border-stone-300 bg-white text-stone-900 hover:border-[#d97706]`}>
{copied ? "コピーしました" : "この場面の URL をコピー"}
</button>
</div>
<p role="status" className="play-note min-h-5 max-w-sm text-sm leading-relaxed text-stone-600 @2xl:text-right">
{note}
</p>
</div>
</div>
<div className="grid border-t border-stone-200 @2xl:grid-cols-[minmax(0,1fr)_16rem] @4xl:grid-cols-[minmax(0,1fr)_18rem]">
<div className="min-w-0 px-5 pt-5 pb-6 @2xl:px-8">
{/* 試せる番号(フォームの直前に横1列。はみ出した分は横へ送る) */}
<p id={`${uid}-chips`} className="text-sm font-bold text-stone-500">
試せる番号<span className="ml-2 font-normal">件数付きは候補が複数</span>
</p>
<div
role="group"
aria-labelledby={`${uid}-chips`}
className="-mx-5 mt-2 flex gap-2 overflow-x-auto px-5 pb-1 [scrollbar-width:thin] @2xl:-mx-8 @2xl:px-8"
>
{CHIPS.map((d) => (
<ZipChip key={d} d={d} count={DEMO_DATA[d].length > 1 ? DEMO_DATA[d].length : undefined} onPick={tryChip} />
))}
</div>
{/* フォーム本体 */}
<form
className="mt-4 grid max-w-xl gap-6"
onSubmit={(e) => e.preventDefault()}
noValidate
aria-label="住所の入力"
>
<div>
<label htmlFor={`${uid}-zip`} className="flex flex-wrap items-baseline gap-x-3 text-base font-bold">
郵便番号
<span className="text-sm font-normal text-stone-500">全角・ハイフン・〒の有無はどれでも可</span>
</label>
<div className="mt-2 flex items-center gap-4">
<input
ref={zipRef}
id={`${uid}-zip`}
name="postal-code"
autoComplete="postal-code"
inputMode="numeric"
role="combobox"
aria-expanded={!!list}
aria-controls={list ? listId : undefined}
aria-autocomplete="list"
aria-activedescendant={list ? `${listId}-${list.active}` : undefined}
aria-describedby={`${uid}-status`}
value={form.zip}
onChange={(e) => onZip(e.target.value)}
onCompositionStart={() => (composingRef.current = true)}
onCompositionEnd={(e: CompositionEvent<HTMLInputElement>) => {
composingRef.current = false;
processZip(e.currentTarget.value);
}}
onKeyDown={onZipKey}
placeholder="例 530-0001"
className={`${input} zip font-display max-w-48 tracking-wide tabular-nums`}
/>
<span
className={`text-sm font-bold text-[#b45309] transition-opacity duration-150 motion-reduce:transition-none ${searching ? "opacity-100" : "opacity-0"}`}
aria-hidden="true"
>
検索中
</span>
</div>
{list && (
<ul
id={listId}
role="listbox"
aria-label={`${list.items.length}件の候補`}
className="mt-1 max-h-72 overflow-y-auto border border-stone-300 bg-white"
>
{list.items.map((c, i) => (
<li
key={c.town}
id={`${listId}-${i}`}
role="option"
aria-selected={i === list.active}
onMouseDown={(e) => e.preventDefault()}
onClick={() => choose(i)}
onMouseEnter={() => openList({ ...list, active: i })}
className={`cursor-pointer border-b border-stone-100 px-3 py-3 text-[15px] leading-snug last:border-b-0 [overflow-wrap:anywhere] ${
i === list.active ? "bg-amber-50 shadow-[inset_3px_0_0_#d97706]" : ""
}`}
>
<span className="block text-sm text-stone-500">
{c.pref}
{c.city}
</span>
<span className="block font-bold text-stone-900">{c.town}</span>
</li>
))}
</ul>
)}
<p
id={`${uid}-status`}
role="status"
aria-live="polite"
className={`mt-2 min-h-6 text-[15px] leading-relaxed ${status.tone === "warn" ? "font-bold text-[#b45309]" : "text-stone-600"}`}
>
{status.text}
</p>
</div>
<div>
<label htmlFor={`${uid}-pref`} className="block text-base font-bold">
都道府県
</label>
<select
id={`${uid}-pref`}
name="address-level1"
autoComplete="address-level1"
value={form.pref}
onChange={(e) => onField("pref", e.target.value)}
className={`${input} mt-2 max-w-60 appearance-none bg-[length:12px] bg-[right_0.9rem_center] bg-no-repeat pr-9 ${flashCls("pref")}`}
style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' fill='none' stroke='%2378716c' stroke-width='1.5'/%3E%3C/svg%3E")`,
}}
>
<option value="">選んでください</option>
{PREFS.map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</select>
{suggestionSlot("pref")}
</div>
<div>
<label htmlFor={`${uid}-city`} className="block text-base font-bold">
市区町村
</label>
<input
id={`${uid}-city`}
name="address-level2"
autoComplete="address-level2"
value={form.city}
onChange={(e) => onField("city", e.target.value)}
className={`${input} mt-2 ${flashCls("city")}`}
/>
{suggestionSlot("city")}
</div>
<div>
<label htmlFor={`${uid}-line1`} className="flex flex-wrap items-baseline gap-x-3 text-base font-bold">
町名・番地
<span className="text-sm font-normal text-stone-500">例 梅田2-3-4</span>
</label>
<input
ref={line1Ref}
id={`${uid}-line1`}
name="address-line1"
autoComplete="address-line1"
value={form.line1}
onChange={(e) => onField("line1", e.target.value)}
className={`${input} line1 mt-2 ${flashCls("line1")}`}
/>
{suggestionSlot("line1")}
</div>
<div>
<label htmlFor={`${uid}-line2`} className="flex flex-wrap items-baseline gap-x-3 text-base font-bold">
建物名・部屋番号
<span className="text-sm font-normal text-stone-500">任意</span>
</label>
<input
id={`${uid}-line2`}
name="address-line2"
autoComplete="address-line2"
value={form.line2}
onChange={(e) => onField("line2", e.target.value)}
className={`${input} mt-2 ${flashCls("line2")}`}
/>
</div>
</form>
{/* 応答の遅れ・失敗(場面ごとに既定値が入っているので、ふだんは閉じておく) */}
<details className="settings group mt-6 border-t border-stone-200 pt-3 text-sm text-stone-600">
<summary className="flex min-h-11 w-fit cursor-pointer list-none items-center gap-2 font-bold text-stone-500 outline-none hover:text-stone-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#d97706] [&::-webkit-details-marker]:hidden">
<span aria-hidden="true" className="font-display inline-block w-3 text-center group-open:hidden">+</span>
<span aria-hidden="true" className="font-display hidden w-3 text-center group-open:inline-block">−</span>
応答の設定(遅れ {(delay / 1000).toFixed(1)}秒{fail ? "・失敗させる" : ""})
</summary>
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 pb-1">
<label className="flex min-h-11 items-center gap-3">
<span className="font-bold text-stone-500">応答の遅れ</span>
<input
type="range"
min={0}
max={3000}
step={100}
value={delay}
onChange={(e) => {
const v = Number(e.target.value);
delayRef.current = v;
setDelay(v);
}}
className="delay w-36 accent-[#d97706] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[#d97706]"
aria-valuetext={`${(delay / 1000).toFixed(1)}秒`}
/>
<span className="font-display w-12 tabular-nums text-stone-900">{(delay / 1000).toFixed(1)}秒</span>
</label>
<label className="flex min-h-11 items-center gap-2">
<input
type="checkbox"
checked={fail}
onChange={(e) => {
failRef.current = e.target.checked;
setFail(e.target.checked);
}}
className="h-5 w-5 accent-[#d97706] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#d97706]"
/>
取得に失敗させる
</label>
</div>
</details>
</div>
{/* よくある実装の各欄(詳細の比較表) */}
<aside
className="border-t border-stone-200 bg-stone-50/60 px-5 py-7 @2xl:px-8 @2xl:border-l @2xl:border-t-0 @2xl:px-6"
aria-labelledby={`${uid}-naive`}
>
<div className="@2xl:sticky @2xl:top-48">
<h3 id={`${uid}-naive`} className="text-sm font-bold text-stone-500">
よくある実装の各欄
</h3>
<p className={`diff mt-2 text-[15px] leading-[1.7] [overflow-wrap:anywhere] ${diff.hit ? "font-bold text-stone-900" : "text-stone-500"}`}>
{diff.hit && <span aria-hidden="true" className="mr-2 inline-block h-2.5 w-2.5 bg-[#d97706]" />}
{diff.text}
</p>
<dl className="mt-5 grid gap-3 border-t border-stone-200 pt-5 text-[15px] leading-relaxed">
{(["zip", "pref", "city", "line1", "line2"] as const).map((key) => {
const v = naive.f[key];
const differs = naive.touched && key !== "zip" && v !== form[key];
return (
<div key={key} className="grid grid-cols-[5.5rem_minmax(0,1fr)] gap-2">
<dt className="text-sm text-stone-500">{key === "zip" ? "郵便番号" : LABEL[key]}</dt>
<dd className={`[overflow-wrap:anywhere] ${differs ? "font-bold text-[#b45309]" : "text-stone-800"}`}>
{v || <span className="text-stone-400">空欄</span>}
</dd>
</div>
);
})}
</dl>
<p className="mt-6 border-t border-stone-200 pt-5 text-sm leading-relaxed text-stone-500">
よくある実装は、届いた応答の1件目で都道府県・市区町村・町名の欄を上書きし、前の検索を止めず、応答が届くたびに町名・番地の欄へカーソルを移します。
</p>
</div>
</aside>
</div>
{/* 変わった欄の色の知らせ(背景色を短く戻す)。動きを減らす設定では、色を付けて外すだけにする */}
<style>{`
@keyframes jpaa-flash-a { from { background-color: #fde68a; } }
@keyframes jpaa-flash-b { from { background-color: #fde68a; } }
@keyframes jpaa-strip-in { from { background-color: #fef3c7; } }
.jpaa-flash-a { animation: jpaa-flash-a 1.2s ease-out; }
.jpaa-flash-b { animation: jpaa-flash-b 1.2s ease-out; }
.jpaa-strip { animation: jpaa-strip-in 0.9s ease-out; }
@media (prefers-reduced-motion: reduce) {
.jpaa-flash-a, .jpaa-flash-b { animation: none; background-color: #fef3c7; }
.jpaa-strip { animation: none; }
}
`}</style>
</>
);
}
function ZipChip({ d, count, onPick }: { d: string; count?: number; onPick: (d: string) => void }) {
const c = DEMO_DATA[d][0];
return (
<button
type="button"
data-zip={d}
onClick={() => onPick(d)}
className="chip flex min-h-11 shrink-0 flex-col items-start justify-center border border-stone-300 bg-white px-2.5 py-1 text-left outline-none transition-colors hover:border-[#d97706] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#d97706] motion-reduce:transition-none"
>
<span className="font-display text-[14px] font-semibold leading-tight tabular-nums text-stone-900">{formatZip(d)}</span>
<span className="whitespace-nowrap text-[12px] leading-tight text-stone-500">
{c.city}
{count ? `・${count}件` : ""}
</span>
</button>
);
}
const EMBED_CODE = `import AddressAutofill, { type AddressLookup } from "@/components/jp-address-autofill";
// 自社の住所検索 API につなぐ例。signal で前の検索を中断する
const lookup: AddressLookup = async (zip, signal) => {
const res = await fetch(\`/api/postal/\${zip}\`, { signal });
if (!res.ok) throw new Error(String(res.status));
return (await res.json()) as { pref: string; city: string; town: string }[];
};
export default function ShippingForm() {
return <AddressAutofill lookup={lookup} />;
}`;
function Embed({ uid }: { uid: string }) {
const [done, setDone] = useState(false);
return (
<div className="grid gap-6 border-t border-stone-200 px-5 py-7 @2xl:px-8 @4xl:grid-cols-[18rem_minmax(0,1fr)]">
<div>
<h3 id={`${uid}-embed`} className="text-base font-bold">
自社のフォームへ組み込む
</h3>
<p className="mt-2 text-[15px] leading-[1.85] text-stone-600">
データの取り出し口は <code className="font-mono text-[13px]">lookup(zip, signal)</code> の1つだけです。7桁にそろえた番号と中断用の signal を受け取り、候補の配列を返せば、空いている欄だけを埋める処理・候補の選択・元に戻すがそのまま使えます。
</p>
<button
type="button"
onClick={async () => {
try {
await navigator.clipboard.writeText(EMBED_CODE);
setDone(true);
} catch {
setDone(false);
}
}}
className="mt-4 inline-flex min-h-11 items-center justify-center border border-stone-900 bg-white px-4 text-[15px] font-bold text-stone-900 outline-none transition-colors hover:bg-stone-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#d97706] motion-reduce:transition-none"
>
{done ? "コピーしました" : "コードをコピー"}
</button>
</div>
<pre
aria-labelledby={`${uid}-embed`}
className="overflow-x-auto bg-stone-950 p-5 font-mono text-[12px] leading-[1.7] text-stone-100"
style={{ borderTop: `2px solid ${AMBER}` }}
>
<code>{EMBED_CODE}</code>
</pre>
</div>
);
}
Add to your project via shadcn CLI
npx shadcn@latest add https://designs.first-ch.com/r/jp-address-autofill.json