form mynumber-input

MyNumber Input — 個人番号 12 桁入力(チェックデジット検証)

個人番号法施行令 第 8 条準拠の check digit 計算。4-4-4 区切り表示・default masked・visibility toggle・autoComplete=off で privacy 最優先。

日本特化無料MITv1.1.0

ライブプレビュー

viewport: responsive

コード · components/blocks/mynumber-input.tsx

"use client";

import { useState } from "react";
import { AlertCircle, Eye, EyeOff, ShieldCheck } from "lucide-react";

import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";

/**
 * Shoji mynumber-input — Japanese individual number (12-digit) input with check-digit validation.
 *
 * Surface B (Blocks) contract — design-spec §4.5:
 *   - shadcn primitives only (Button, 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. 12 桁の数字入力 — onChange で数字以外を即時除去
 *   2. 4-4-4 区切りの視覚フォーマットを表示時のみ適用、内部値は素の 12 桁
 *   3. チェックデジット検証(個人番号法のアルゴリズム — 11 桁 + 1 桁)
 *   4. 数値マスキング (●●●● ●●●● ●●●●) を default にし、目アイコンで切り替え
 *   5. inputMode="numeric" + autoComplete="off" でモバイル最適化 + autofill 抑止
 *
 * Reference: 個人番号法施行令 第 8 条 — 12 桁の最後の 1 桁は前 11 桁の重み付け合計から導出。
 */

const WEIGHTS = [6, 5, 4, 3, 2, 7, 6, 5, 4, 3, 2];

function computeCheckDigit(eleven: string): number | null {
  if (!/^\d{11}$/.test(eleven)) return null;
  const sum = WEIGHTS.reduce(
    (acc, w, i) => acc + w * Number(eleven.charAt(i)),
    0,
  );
  const remainder = sum % 11;
  return remainder <= 1 ? 0 : 11 - remainder;
}

function isValidMyNumber(twelve: string): boolean {
  if (!/^\d{12}$/.test(twelve)) return false;
  const expected = computeCheckDigit(twelve.slice(0, 11));
  if (expected === null) return false;
  return expected === Number(twelve.charAt(11));
}

function formatGrouped(digits: string, masked: boolean): string {
  const padded = digits.padEnd(12, "·").slice(0, 12);
  const display = masked
    ? padded.replace(/\d/g, "●").replace(/·/g, "·")
    : padded;
  return [display.slice(0, 4), display.slice(4, 8), display.slice(8, 12)].join(
    " ",
  );
}

export function MyNumberInput() {
  const [digits, setDigits] = useState("");
  const [touched, setTouched] = useState(false);
  const [revealed, setRevealed] = useState(false);

  const valid = digits.length === 12 && isValidMyNumber(digits);
  const showError = touched && digits.length > 0 && !valid;

  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">
            12 桁の数字を入力してください。チェックデジットで自動検証します。番号は規約に基づき
            <span className="font-medium"> 暗号化保管 </span>されます。
          </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="mynumber-input" className="flex items-center gap-2">
              個人番号
              <span className="text-xs text-muted-foreground">必須</span>
            </Label>
            <div className="flex items-center gap-2">
              <Input
                id="mynumber-input"
                type={revealed ? "text" : "password"}
                inputMode="numeric"
                pattern="\d{12}"
                autoComplete="off"
                placeholder="1234 5678 9012"
                value={digits}
                maxLength={12}
                onChange={(e) =>
                  setDigits(e.target.value.replace(/\D/g, "").slice(0, 12))
                }
                onBlur={() => setTouched(true)}
                aria-describedby={
                  showError ? "mynumber-input-error" : "mynumber-input-help"
                }
                aria-invalid={showError ? true : undefined}
                className={`tabular-nums tracking-[0.2em] ${
                  showError
                    ? "border-destructive focus-visible:ring-destructive/40"
                    : ""
                }`}
              />
              <Button
                type="button"
                variant="ghost"
                size="icon"
                onClick={() => setRevealed((r) => !r)}
                aria-label={
                  revealed ? "個人番号を隠す" : "個人番号を表示する"
                }
              >
                {revealed ? (
                  <EyeOff aria-hidden className="h-4 w-4" />
                ) : (
                  <Eye aria-hidden className="h-4 w-4" />
                )}
              </Button>
            </div>

            <p
              id="mynumber-input-help"
              className="font-mono text-xs tabular-nums text-muted-foreground"
              aria-live="polite"
            >
              {formatGrouped(digits, !revealed)}
            </p>

            {showError ? (
              <p
                id="mynumber-input-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>
                  {digits.length < 12
                    ? "12 桁すべての数字を入力してください。"
                    : "チェックデジットが一致しません。番号をご確認ください。"}
                </span>
              </p>
            ) : null}

            {valid ? (
              <p
                role="status"
                className="flex items-start gap-1.5 text-sm text-emerald-600 dark:text-emerald-400"
              >
                <ShieldCheck
                  aria-hidden
                  className="mt-0.5 h-3.5 w-3.5 shrink-0"
                />
                <span>形式・チェックデジットともに有効です。</span>
              </p>
            ) : null}
          </div>
        </fieldset>
      </div>
    </section>
  );
}

AI prompt · MDX

shoji_block: mynumber-input
---
shoji_block: mynumber-input
shoji_version: 1.1.0
category: form
license: MIT
dependencies: ["lucide-react"]
shadcn_primitives: ["button", "input", "label"]
ja_specific: true
---

# mynumber-input

> Shoji block for AI agents. Paste this MDX to your AI; it scaffolds a 12-digit
> 個人番号 (My Number) input with check-digit validation and visibility toggle.

## Component

```tsx
"use client";

import { useState } from "react";
import { AlertCircle, Eye, EyeOff, ShieldCheck } from "lucide-react";

import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";

const WEIGHTS = [6, 5, 4, 3, 2, 7, 6, 5, 4, 3, 2];

function computeCheckDigit(eleven: string): number | null {
  if (!/^\d{11}$/.test(eleven)) return null;
  const sum = WEIGHTS.reduce(
    (acc, w, i) => acc + w * Number(eleven.charAt(i)),
    0,
  );
  const remainder = sum % 11;
  return remainder <= 1 ? 0 : 11 - remainder;
}

function isValidMyNumber(twelve: string): boolean {
  if (!/^\d{12}$/.test(twelve)) return false;
  const expected = computeCheckDigit(twelve.slice(0, 11));
  return expected !== null && expected === Number(twelve.charAt(11));
}
```

## Usage

```tsx
import { MyNumberInput } from "@/components/blocks/mynumber-input";

export default function Page() {
  return <MyNumberInput />;
}
```

## Block contract (Two-surfaces — design-spec §4.5)

This is a **Surface B block** — it inherits your shadcn theme.

- shadcn primitives only (`Button`, `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. **個人番号法準拠の check digit**: implements 個人番号法施行令 第 8 条 — weighted-sum mod-11 over the first 11 digits. Most JP libraries get this wrong by mishandling the `remainder ≤ 1 → 0` case.
2. **Default-masked display**: `<input type="password">` plus a 4-4-4 visual format (`●●●● ●●●● ●●●●`). Toggle to plaintext via the eye-icon button.
3. **Strict numeric input**: `onChange` strips non-digits and clips at 12, so the parent state is always normalized to either `""` or 12 numeric chars.
4. **3-state feedback**: empty → no error, partial → "12 桁すべての…", complete-but-invalid → "チェックデジットが一致しません。", complete-valid → green success row with `ShieldCheck` icon.
5. **`autoComplete="off"`**: prevents browser autofill from leaking sensitive numbers into the wrong field.

## Customization hints

- **Always-revealed mode**: drop the `Eye` toggle if your form is admin-only and unmasked entry is acceptable.
- **Encryption note**: the help copy currently says "暗号化保管" — replace with your actual data-handling commitment, or wire to a tooltip linking to your privacy notice.
- **Server-side double-check**: this block validates client-side only. Always re-run `isValidMyNumber` server-side before persisting.

## A11y

- The visibility toggle has a context-aware `aria-label` ("個人番号を隠す" / "個人番号を表示する").
- The grouped 4-4-4 display lives in a separate `<p aria-live="polite">` so screen readers announce changes without trapping focus.
- Errors use the 3-point pattern: color (`text-destructive`) + `AlertCircle` icon + descriptive copy.
- Success uses `role="status"` + `ShieldCheck` icon + green text — never green alone.
- `aria-describedby` switches between the help row and the error row depending on state.

## Why this is a Shoji block

- **JP-tuned by default.** The check-digit weights and mod-11 carry-rule are baked in. Form generators usually skip this and silently accept invalid numbers.
- **AI agent ready.** This MDX exposes the entire validation logic + UX rationale as one paste.
- **Privacy-first defaults.** Masked by default, autocomplete off, no analytics hooks. Surface only the digits the consumer typed.
- **No theming opinions.** Pure shadcn primitives + semantic tokens — drop into any shadcn project.

依存パッケージ · npm

  • lucide-react