form phone-jp
Phone JP — 携帯 / 固定 / IP / フリーダイヤル 自動整形
0X0 区分を検出して 3-4-4 / 2-4-4 / 4-3-3 で自動整形。NFKC で全角数字も受理。format-on-blur で IME と競合しない。
日本特化無料MITv1.1.0
コード · components/blocks/phone-jp.tsx
"use client";
import { useState } from "react";
import { AlertCircle, CheckCircle2, Phone } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
/**
* Shoji phone-jp — Japanese phone number input with auto-format and live carrier detection.
*
* Surface B (Blocks) contract — design-spec §4.5:
* - shadcn primitives only (Input, Label)
* - shadcn semantic tokens only (bg-background, text-foreground, border, text-destructive)
* - Tailwind default scale + default radius — no Shoji-specific overrides
*
* What this block solves (ja_specific = true):
* 1. 自動ハイフン挿入(携帯 3-4-4 / 固定 2-4-4 / IP 050-4-4 / フリー 0120-3-3)
* 2. 入力中はフォーマットしない(IME と競合しない)→ onBlur で初めて整形
* 3. 区分検出(携帯 / 固定 / IP / フリーダイヤル)をリアルタイム表示
* 4. 半角化(NFKC)で全角数字も受理
* 5. inputMode="tel" + autoComplete="tel" でモバイル & autofill 最適化
*/
type PhoneCategory = "mobile" | "fixed" | "ip" | "tollfree" | "unknown";
type PhoneInfo = {
category: PhoneCategory;
formatted: string;
digits: string;
valid: boolean;
};
function categorize(digits: string): PhoneCategory {
if (digits.startsWith("070") || digits.startsWith("080") || digits.startsWith("090")) {
return "mobile";
}
if (digits.startsWith("050")) return "ip";
if (digits.startsWith("0120") || digits.startsWith("0800")) return "tollfree";
if (digits.startsWith("0")) return "fixed";
return "unknown";
}
function formatPhone(raw: string): PhoneInfo {
const digits = raw.normalize("NFKC").replace(/\D/g, "");
const category = categorize(digits);
let formatted = digits;
let valid = false;
if (category === "mobile" && digits.length === 11) {
formatted = `${digits.slice(0, 3)}-${digits.slice(3, 7)}-${digits.slice(7, 11)}`;
valid = true;
} else if (category === "ip" && digits.length === 11) {
formatted = `${digits.slice(0, 3)}-${digits.slice(3, 7)}-${digits.slice(7, 11)}`;
valid = true;
} else if (category === "tollfree" && digits.length === 10) {
formatted = `${digits.slice(0, 4)}-${digits.slice(4, 7)}-${digits.slice(7, 10)}`;
valid = true;
} else if (category === "fixed" && digits.length === 10) {
formatted = `${digits.slice(0, 2)}-${digits.slice(2, 6)}-${digits.slice(6, 10)}`;
valid = true;
} else {
formatted = digits;
}
return { category, formatted, digits, valid };
}
const CATEGORY_LABEL: Record<PhoneCategory, string> = {
mobile: "携帯電話",
fixed: "固定電話",
ip: "IP 電話 (050)",
tollfree: "フリーダイヤル",
unknown: "区分判定中",
};
export function PhoneJp() {
const [raw, setRaw] = useState("");
const [touched, setTouched] = useState(false);
const info = formatPhone(raw);
const handleBlur = () => {
setTouched(true);
if (info.valid) setRaw(info.formatted);
};
const showError = touched && !info.valid && info.digits.length > 0;
const isPartial = info.digits.length > 0 && info.digits.length < 10;
return (
<section className="bg-background text-foreground">
<div className="mx-auto w-full max-w-xl px-6 py-16 lg:py-24">
<header className="mb-10 flex flex-col gap-2">
<p className="font-mono text-xs uppercase tracking-[0.18em] text-muted-foreground">
Form · 日本特化
</p>
<h2 className="text-2xl font-semibold leading-[1.3] tracking-tight lg:text-3xl">
電話番号
</h2>
<p className="text-sm leading-[1.75] text-muted-foreground">
ハイフンなし・全角・半角どれでも入力できます。フォーカスを外したタイミングで自動整形します。
</p>
</header>
<fieldset className="flex flex-col gap-6 rounded-lg border border-border bg-card p-6 sm:p-8">
<legend className="sr-only">電話番号の入力</legend>
<div className="flex flex-col gap-2">
<Label htmlFor="phone-jp-input" className="flex items-center gap-2">
電話番号
<span className="text-xs text-muted-foreground">必須</span>
</Label>
<Input
id="phone-jp-input"
type="tel"
inputMode="tel"
autoComplete="tel"
placeholder="090-1234-5678"
value={raw}
onChange={(e) => setRaw(e.target.value)}
onBlur={handleBlur}
aria-describedby={
showError ? "phone-jp-error" : "phone-jp-meta"
}
aria-invalid={showError ? true : undefined}
className={`tabular-nums ${
showError
? "border-destructive focus-visible:ring-destructive/40"
: ""
}`}
/>
<p
id="phone-jp-meta"
className="flex items-center gap-2 text-xs text-muted-foreground"
aria-live="polite"
>
<Phone aria-hidden className="h-3.5 w-3.5" />
<span>
{info.digits.length === 0
? "入力するとリアルタイムで区分を検出します。"
: `${CATEGORY_LABEL[info.category]} · ${info.digits.length} 桁`}
</span>
</p>
{showError ? (
<p
id="phone-jp-error"
className="flex items-start gap-1.5 text-sm text-destructive"
>
<AlertCircle
aria-hidden
className="mt-0.5 h-3.5 w-3.5 shrink-0"
/>
<span>
{isPartial
? "番号が短いようです。市外局番から正しく入力してください。"
: info.category === "unknown"
? "0 から始まる電話番号を入力してください。"
: "桁数が一致しません。番号をもう一度ご確認ください。"}
</span>
</p>
) : null}
{info.valid ? (
<p
role="status"
className="flex items-start gap-1.5 text-sm text-emerald-600 dark:text-emerald-400"
>
<CheckCircle2
aria-hidden
className="mt-0.5 h-3.5 w-3.5 shrink-0"
/>
<span>
整形しました:
<span className="ml-1 font-mono tabular-nums">
{info.formatted}
</span>
</span>
</p>
) : null}
</div>
</fieldset>
</div>
</section>
);
}
AI prompt · MDX
shoji_block: phone-jp---
shoji_block: phone-jp
shoji_version: 1.1.0
category: form
license: MIT
dependencies: ["lucide-react"]
shadcn_primitives: ["input", "label"]
ja_specific: true
---
# phone-jp
> Shoji block for AI agents. Paste this MDX to your AI; it scaffolds a Japanese
> phone number input with auto-format and live carrier detection (mobile / fixed
> / IP / toll-free).
## Component
```tsx
"use client";
import { useState } from "react";
import { AlertCircle, CheckCircle2, Phone } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
function categorize(digits: string) {
if (/^0[789]0/.test(digits)) return "mobile"; // 070 / 080 / 090
if (digits.startsWith("050")) return "ip";
if (digits.startsWith("0120") || digits.startsWith("0800")) return "tollfree";
if (digits.startsWith("0")) return "fixed";
return "unknown";
}
function formatPhone(raw: string) {
const digits = raw.normalize("NFKC").replace(/\D/g, "");
const category = categorize(digits);
// mobile / ip → 3-4-4 ; fixed → 2-4-4 ; tollfree → 4-3-3
// Returns { digits, formatted, category, valid }
}
```
## Usage
```tsx
import { PhoneJp } from "@/components/blocks/phone-jp";
export default function Page() {
return <PhoneJp />;
}
```
## Block contract (Two-surfaces — design-spec §4.5)
This is a **Surface B block** — it inherits your shadcn theme.
- shadcn primitives only (`Input`, `Label`)
- shadcn semantic tokens only (`bg-background`, `text-foreground`, `border`, `text-destructive`)
- Tailwind default scale + your shadcn radius
## What it solves (ja_specific = true)
1. **Carrier-aware formatting**: 携帯 / IP は 3-4-4、固定電話は 2-4-4、フリーダイヤルは 4-3-3 の刻みで自動整形。1 ブロックで JP の 4 区分すべてをカバー。
2. **Format on blur, never on type**: prohibited.md の "入力中バリデーション禁止" を遵守。IME や半角/全角混在入力と競合しない。
3. **Live detection**: 入力中に区分(携帯 / 固定 / IP / フリー)と桁数を `aria-live="polite"` でフィードバック。
4. **NFKC 正規化**: 全角数字 (090…) も自動で半角に。海外テンプレートが詰む典型ケース。
5. **3-state validation**: 未入力 / 桁不足 / 整形成功 で文言を切り替え、原因がわかるエラーコピーを inline で出す。
## Customization hints
- **Disable category badge**: drop the `phone-jp-meta` `<p>` if your form is too dense for the live hint.
- **Force mobile only**: tighten `categorize` to return `unknown` for non-mobile prefixes — useful for SMS-OTP flows.
- **International support**: this block is JP-only by design. For multi-locale forms, fork into `phone-international` and gate by user locale.
- **Error tone**: replace the destructive copy with softer language for B2B contexts where users are typing IP-PBX numbers.
## A11y
- The category / digit hint and the error message both live in `aria-describedby` slots, swapped based on validity state.
- Live region (`aria-live="polite"`) announces category changes without stealing focus.
- Errors use the 3-point pattern: color (`text-destructive`) + `AlertCircle` + descriptive copy.
- Success uses `role="status"` + `CheckCircle2` + green text — never green alone.
- `<input type="tel" inputMode="tel" autoComplete="tel">` triggers the dialer keyboard on iOS / Android and lets OS autofill take over.
## Why this is a Shoji block
- **JP-tuned by default.** The 4 carrier tiers and their digit shapes (3-4-4 / 2-4-4 / 4-3-3) are baked in. Most templates accept anything with 10–11 digits.
- **AI agent ready.** This MDX is paste-ready — drop into Claude / ChatGPT and you get back a wired-up component with the rationale embedded.
- **No theming opinions.** Pure shadcn primitives + semantic tokens — drop into any shadcn project.