form wareki-date

Wareki Date — 西暦↔和暦の生年月日入力

西暦の年・月・日を入力すると、令和/平成/昭和/大正/明治の和暦をリアルタイム算出。元年表記、境界日付、a11y 完備。

日本特化無料MITv1.1.0

ライブプレビュー

viewport: responsive

コード · components/blocks/wareki-date.tsx

"use client";

import { useMemo, useState } from "react";
import { AlertCircle, Calendar } from "lucide-react";

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

/**
 * Shoji wareki-date — Japanese era date input with live 和暦 display.
 *
 * 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. 西暦入力 → 和暦をリアルタイム算出して並べて表示(履歴書・契約書フォーマットに直結)
 *   2. 令和 / 平成 / 昭和 / 大正 / 明治 の境界を一発で正しく判定
 *   3. 元年表記(「令和元年」など)も自動
 *   4. 年・月・日を `flex` + 固定幅で配置(grid 均等割り禁止 / 年 w-28・月 w-20・日 w-20)
 *   5. onBlur まで validation を遅延、空入力は穏やかな inline-error で誘導
 */

type Era = {
  name: string;
  short: string;
  startTimestamp: number; // ms since epoch (UTC midnight of start date)
};

// Era boundaries (UTC, midnight Tokyo). Picked at start-of-day for simplicity —
// production projects may want to switch to JST + intra-day boundary checks.
const ERAS: Era[] = [
  { name: "令和", short: "R", startTimestamp: Date.UTC(2019, 4, 1) },
  { name: "平成", short: "H", startTimestamp: Date.UTC(1989, 0, 8) },
  { name: "昭和", short: "S", startTimestamp: Date.UTC(1926, 11, 25) },
  { name: "大正", short: "T", startTimestamp: Date.UTC(1912, 6, 30) },
  { name: "明治", short: "M", startTimestamp: Date.UTC(1868, 0, 25) },
];

type WarekiResult =
  | { kind: "ok"; era: Era; eraYear: number }
  | { kind: "out-of-range" }
  | { kind: "invalid" };

function computeWareki(year: number, month: number, day: number): WarekiResult {
  if (
    !Number.isFinite(year) ||
    !Number.isFinite(month) ||
    !Number.isFinite(day) ||
    month < 1 ||
    month > 12 ||
    day < 1 ||
    day > 31
  ) {
    return { kind: "invalid" };
  }
  const ts = Date.UTC(year, month - 1, day);
  if (Number.isNaN(ts)) return { kind: "invalid" };

  const era = ERAS.find((e) => ts >= e.startTimestamp);
  if (!era) return { kind: "out-of-range" };
  const startYear = new Date(era.startTimestamp).getUTCFullYear();
  return { kind: "ok", era, eraYear: year - startYear + 1 };
}

function formatWareki(result: WarekiResult): string {
  if (result.kind === "invalid") return "—";
  if (result.kind === "out-of-range")
    return "明治より前の日付には対応していません。";
  const { era, eraYear } = result;
  const yearLabel = eraYear === 1 ? "元" : `${eraYear}`;
  return `${era.name}${yearLabel}年`;
}

export function WarekiDate() {
  const [year, setYear] = useState("1990");
  const [month, setMonth] = useState("1");
  const [day, setDay] = useState("15");
  const [touched, setTouched] = useState(false);

  const wareki = useMemo(() => {
    const y = Number(year);
    const m = Number(month);
    const d = Number(day);
    return computeWareki(y, m, d);
  }, [year, month, day]);

  const showError = touched && wareki.kind !== "ok";
  const isPartial = !year || !month || !day;

  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="wareki-year">西暦(年・月・日)</Label>
            <div className="flex items-end gap-2">
              <div className="flex flex-col gap-1">
                <Input
                  id="wareki-year"
                  inputMode="numeric"
                  pattern="\d*"
                  autoComplete="bday-year"
                  value={year}
                  onChange={(e) => setYear(e.target.value.replace(/\D/g, ""))}
                  onBlur={() => setTouched(true)}
                  aria-invalid={showError ? true : undefined}
                  className={`w-28 text-center tabular-nums ${
                    showError
                      ? "border-destructive focus-visible:ring-destructive/40"
                      : ""
                  }`}
                />
                <span className="text-center text-xs text-muted-foreground">
                  年
                </span>
              </div>
              <div className="flex flex-col gap-1">
                <Input
                  id="wareki-month"
                  inputMode="numeric"
                  pattern="\d*"
                  autoComplete="bday-month"
                  value={month}
                  onChange={(e) => setMonth(e.target.value.replace(/\D/g, ""))}
                  onBlur={() => setTouched(true)}
                  aria-invalid={showError ? true : undefined}
                  className={`w-20 text-center tabular-nums ${
                    showError
                      ? "border-destructive focus-visible:ring-destructive/40"
                      : ""
                  }`}
                />
                <span className="text-center text-xs text-muted-foreground">
                  月
                </span>
              </div>
              <div className="flex flex-col gap-1">
                <Input
                  id="wareki-day"
                  inputMode="numeric"
                  pattern="\d*"
                  autoComplete="bday-day"
                  value={day}
                  onChange={(e) => setDay(e.target.value.replace(/\D/g, ""))}
                  onBlur={() => setTouched(true)}
                  aria-invalid={showError ? true : undefined}
                  className={`w-20 text-center tabular-nums ${
                    showError
                      ? "border-destructive focus-visible:ring-destructive/40"
                      : ""
                  }`}
                />
                <span className="text-center text-xs text-muted-foreground">
                  日
                </span>
              </div>
            </div>
            {showError ? (
              <p 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
                    ? "年・月・日をすべて入力してください。"
                    : wareki.kind === "out-of-range"
                      ? "明治以前の日付には対応していません。"
                      : "正しい日付を入力してください。"}
                </span>
              </p>
            ) : null}
          </div>

          <div
            role="group"
            aria-labelledby="wareki-display-label"
            className="flex items-center justify-between rounded-md border border-dashed border-border bg-muted/40 px-4 py-3"
          >
            <div className="flex items-center gap-2 text-xs uppercase tracking-[0.18em] text-muted-foreground">
              <Calendar aria-hidden className="h-3.5 w-3.5" />
              <span id="wareki-display-label">和暦</span>
            </div>
            <output
              htmlFor="wareki-year wareki-month wareki-day"
              className="text-base font-medium tabular-nums text-foreground"
            >
              {wareki.kind === "ok"
                ? `${formatWareki(wareki)}${month}月${day}日`
                : "—"}
            </output>
          </div>
        </fieldset>
      </div>
    </section>
  );
}

AI prompt · MDX

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

# wareki-date

> Shoji block for AI agents. Paste this MDX to your AI; it scaffolds a 西暦 ↔ 和暦
> date input that handles 令和 / 平成 / 昭和 / 大正 / 明治 boundaries correctly.

## Component

```tsx
"use client";

import { useMemo, useState } from "react";
import { AlertCircle, Calendar } from "lucide-react";

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

const ERAS = [
  { name: "令和", short: "R", startTimestamp: Date.UTC(2019, 4, 1) },
  { name: "平成", short: "H", startTimestamp: Date.UTC(1989, 0, 8) },
  { name: "昭和", short: "S", startTimestamp: Date.UTC(1926, 11, 25) },
  { name: "大正", short: "T", startTimestamp: Date.UTC(1912, 6, 30) },
  { name: "明治", short: "M", startTimestamp: Date.UTC(1868, 0, 25) },
];

function computeWareki(year: number, month: number, day: number) {
  const ts = Date.UTC(year, month - 1, day);
  const era = ERAS.find((e) => ts >= e.startTimestamp);
  if (!era) return { kind: "out-of-range" } as const;
  const startYear = new Date(era.startTimestamp).getUTCFullYear();
  return { kind: "ok", era, eraYear: year - startYear + 1 } as const;
}
```

## Usage

```tsx
import { WarekiDate } from "@/components/blocks/wareki-date";

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

## 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`, `bg-muted`)
- Tailwind default scale + your shadcn radius

## What it solves (ja_specific = true)

1. **Live era conversion**: typing 西暦 immediately renders the correct 和暦 below — no toggle, no submit round-trip.
2. **Boundary correctness**: 令和 starts 2019-05-01, 平成 1989-01-08, 昭和 1926-12-25, 大正 1912-07-30, 明治 1868-01-25. All five eras are recognized.
3. **元年 handling**: the first calendar year of an era renders as `〇〇元年` instead of `〇〇1年`.
4. **Layout discipline**: 年 (`w-28`) / 月 (`w-20`) / 日 (`w-20`) are fixed-width per UX guidelines — no equal-width grid that misaligns on mobile.
5. **Sensible validation**: errors fire only after blur, with clear copy distinguishing "未入力" vs "明治以前" vs "不正な日付".

## Customization hints

- **Default value**: change the `useState` defaults to ship with empty fields if you want a clean slate.
- **Display format**: tweak `formatWareki` to e.g. `R6` short notation or `令和6年1月15日` long form.
- **Strict 31-day handling**: this block lets `30/2` slip past as "ok" because `Date.UTC(1990, 1, 30)` rolls forward — wrap with a `Date(...)` round-trip check if your form blocks invalid days.

## A11y

- `<output htmlFor>` ties the 和暦 display to its three input fields, so screen readers announce the live update.
- `<fieldset>` + `<legend class="sr-only">` groups the date inputs under a single accessible name (the visible `<h2>` already provides the visual label).
- Inline error uses color + `AlertCircle` icon + descriptive text (3-point error pattern).
- `inputMode="numeric"` + `autoComplete="bday-year/month/day"` cooperate with mobile keyboards and OS-level autofill.

## Why this is a Shoji block

- **JP-tuned by default.** The era boundary table is hand-verified — most third-party "wareki" libraries get 1989-01-08 wrong by a day.
- **AI agent ready.** This MDX is paste-ready; the component is also small enough to inline into a generation prompt.
- **No theming opinions.** Pure shadcn primitives + semantic tokens — drop into any shadcn project.

依存パッケージ · npm

  • lucide-react