form wareki-date
Wareki Date — 西暦↔和暦の生年月日入力
西暦の年・月・日を入力すると、令和/平成/昭和/大正/明治の和暦をリアルタイム算出。元年表記、境界日付、a11y 完備。
日本特化無料MITv1.1.0
コード · 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.