form phone-jp

Phone JP — 携帯 / 固定 / IP / フリーダイヤル 自動整形

0X0 区分を検出して 3-4-4 / 2-4-4 / 4-3-3 で自動整形。NFKC で全角数字も受理。format-on-blur で IME と競合しない。

日本特化無料MITv1.1.0

ライブプレビュー

viewport: responsive

コード · 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.

依存パッケージ · npm

  • lucide-react