BLK-106Blocks

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} />;
}
Share on X

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