Reactで、和暦と西暦を変換する入力欄を作る方法

​​React​Intl,JavaScript,Playwright,TypeScript

和暦と西暦の年の変換は、元号ごとに「始まった日」を並べた小さな表があれば、足し算と引き算で済みます。年の途中で元号が変わる年(1989年など)は、2つの和暦を日付付きで並べると、その日付がどちらの元号に入るのか迷いません。

ここでは、1つの入力欄に「2026」と打てば「令和8年」、「令和8年」「R8」と打てば「2026年」と出る WarekiInput という部品を作りました。変換の処理は wareki.ts にまとめています。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を esbuild で変換し、tsc で型エラーがないことを確かめたうえで、StrictMode で包んだ開発用のビルドを Playwright で操作しました。日本語入力(IME)での変換中の動き、Firefox と Safari、スマートフォン、スクリーンリーダーでの読み上げは試していません。

白い背景に、「西暦または和暦の年」の入力欄に 1989 と入っていて、その下に水色の大きな文字で「昭和64年(1月7日まで)」「平成元年(1月8日から)」の2行が表示されている
1989年を入力したところ。年の途中で元号が変わるので2行出る

元号の表に、始まった日を持たせる

変換の関数をまとめたファイルです。

ts
// 西暦の年と和暦の年を変換する関数

type Era = { name: string; initial: string; start: [number, number, number] };

// 元号と、始まった日(西暦)。新しい元号ができたら1行足す
const ERAS: Era[] = [
  { name: '明治', initial: 'M', start: [1868, 10, 23] },
  { name: '大正', initial: 'T', start: [1912, 7, 30] },
  { name: '昭和', initial: 'S', start: [1926, 12, 25] },
  { name: '平成', initial: 'H', start: [1989, 1, 8] },
  { name: '令和', initial: 'R', start: [2019, 5, 1] },
];

export const MIN_YEAR = 1873; // 明治6年。これより前は扱わない
export const MAX_YEAR = 9999;

// 1年目は「元年」と書く
const yearText = (n: number) => (n === 1 ? '元' : String(n));

// その元号の最後の日(次の元号の始まりの前日)
function lastDay(next: Era): [number, number] {
  const [y, m, d] = next.start;
  const date = new Date(Date.UTC(y, m - 1, d - 1));
  return [date.getUTCMonth() + 1, date.getUTCDate()];
}

export type Result =
  | { kind: 'empty' }
  | { kind: 'hint'; message: string }
  | { kind: 'error'; message: string }
  | { kind: 'ok'; lines: string[] };

// 西暦の年 → 和暦。年の途中で元号が変わる年は2つ返す
export function toWareki(year: number): string[] {
  const lines: string[] = [];
  ERAS.forEach((era, i) => {
    const next = ERAS[i + 1];
    const [sy, sm, sd] = era.start;
    if (year < sy || (next && year > next.start[0])) return;
    // 次の元号が1月1日に始まる年は、この元号に入らない
    if (next && year === next.start[0] && next.start[1] === 1 && next.start[2] === 1) return;
    let line = `${era.name}${yearText(year - sy + 1)}年`;
    if (year === sy && !(sm === 1 && sd === 1)) line += `(${sm}月${sd}日から)`;
    if (next && year === next.start[0]) {
      const [m, d] = lastDay(next);
      line += `(${m}月${d}日まで)`;
    }
    lines.push(line);
  });
  return lines;
}

// 和暦 → 西暦の年
function fromWareki(era: Era, n: number): Result {
  const i = ERAS.indexOf(era);
  const next = ERAS[i + 1];
  const [sy, sm, sd] = era.start;
  const year = sy + n - 1;
  if (n < 1) return { kind: 'error', message: `${era.name}は元年からです` };
  if (next && year > next.start[0]) {
    const last = next.start[0] - sy + 1;
    return { kind: 'error', message: `${era.name}は${yearText(last)}年までです` };
  }
  if (year < MIN_YEAR) return { kind: 'error', message: `${MIN_YEAR}年(明治6年)より前は扱いません` };
  if (year > MAX_YEAR) return { kind: 'error', message: `${MAX_YEAR}年より後は扱いません` };
  let line = `${year}年`;
  if (year === sy && !(sm === 1 && sd === 1)) line += `(${sm}月${sd}日から)`;
  if (next && year === next.start[0]) {
    const [m, d] = lastDay(next);
    line += `(${m}月${d}日まで)`;
  }
  return { kind: 'ok', lines: [line] };
}

const WESTERN = /^(\d{1,4})年?$/; // 「2026年」のように年が付いた形も受け付ける
const WAREKI = /^(明治|大正|昭和|平成|令和|[MTSHR])(\d{1,4}|元)年?$/;
// 打っている途中の形(元号だけ、元号の1文字目だけ、3桁までの数字)
const ERA_ONLY = /^(明治|大正|昭和|平成|令和|[MTSHR明大昭平令])$/;
const SHORT_NUMBER = /^\d{1,3}$/;

// 入力欄の文字列を読んで、どちら向きかを決めて変換する
export function convert(input: string): Result {
  // 全角の英数字や「㋿」「㍻」を半角・2文字に直し、空白を取る
  const s = input.normalize('NFKC').replace(/\s+/g, '').toUpperCase();
  if (s === '') return { kind: 'empty' };

  // 打っている途中では、エラーにせず続きを促す
  if (ERA_ONLY.test(s)) return { kind: 'hint', message: '続けて年を入力してください' };
  if (SHORT_NUMBER.test(s)) return { kind: 'hint', message: '西暦は4桁で入力してください' };

  const w = s.match(WESTERN);
  if (w) {
    const year = Number(w[1]);
    if (year < MIN_YEAR) return { kind: 'error', message: `${MIN_YEAR}年(明治6年)より前は扱いません` };
    return { kind: 'ok', lines: toWareki(year) };
  }

  const j = s.match(WAREKI);
  if (j) {
    const era = ERAS.find((e) => e.name === j[1] || e.initial === j[1])!;
    const n = j[2] === '元' ? 1 : Number(j[2]);
    return fromWareki(era, n);
  }

  return { kind: 'error', message: '「2026」「令和8年」「R8」のように入力してください' };
}

ERAS は、元号の名前、ローマ字の頭文字、始まった日(西暦)の表です。始まった日は、Unicode CLDR の元号のデータと同じ日付にしました。

西暦から和暦にするときは、その年にかかる元号を表から探し、「西暦の年 − 元号が始まった年 + 1」を和暦の年にします。1年目は「1年」ではなく「元年」と書きます。和暦から西暦にするときは、その逆の計算です。

表にした理由は、和暦から西暦への向きにあります。西暦の日付を和暦の文字にするだけなら、Intl.DateTimeFormat で足ります(後の節で紹介します)。しかし、MDN の Intl.DateTimeFormat の説明のとおり、これは日付を文字に書式化するためのものです。「平成31年」のような文字を読んで西暦の年を出すには、元号が始まった日を、自分で表として持つ必要があります。新しい元号ができたときは、ERAS に1行足します。

元号が変わる年は、2つの和暦を日付付きで出す

1989年は、1月7日までが昭和64年、1月8日からが平成元年です。toWareki は、年の途中で元号が変わる年には、2つの和暦を返します。前の元号には「(何月何日まで)」、新しい元号には「(何月何日から)」を付けました。前の元号の最後の日は、次の元号が始まった日の前日として計算しています。

convert に年を渡して確かめると、次のように表示されました。

入力 表示
2026 令和8年
1989 昭和64年(1月7日まで)/平成元年(1月8日から)
2019 平成31年(4月30日まで)/令和元年(5月1日から)
1926 大正15年(12月24日まで)/昭和元年(12月25日から)
昭和64 1989年(1月7日まで)
令和元年 2019年(5月1日から)
昭和65 昭和は64年までです
H32 平成は31年までです
S0 昭和は元年からです

和暦から西暦への向きでも、元号の最初の年と最後の年には日付を添えます。「昭和64年」は1989年の1月7日までしかないので、「1989年(1月7日まで)」と出ます。元号の最後の年を超える年(昭和65年、平成32年)は、その元号が何年までかを伝えるエラーにしました。

表が正しいかどうかは、Intl.DateTimeFormat で確かめました。1873年1月1日から2100年12月31日までの毎日(83,275日)を ja-JP-u-ca-japanese で和暦にし、年ごとに「元号と年」と、その和暦が何月何日から何月何日までかを集めて、toWareki の結果と比べました。Node.js でも Chromium でも、食い違う年はありませんでした。1873年から9999年までのすべての年について、toWareki が出した和暦を convert に入れ直すと、どれも元の西暦の年に戻りました。

1873年(明治6年)より前の年は扱わない

この部品は、1873年(明治6年)より前の年を受け付けません。「1872」や「明治5」と打つと、「1873年(明治6年)より前は扱いません」と出します。

MDN にある Intl.supportedValuesOf() の「対応している暦」の表によると、japanese の暦が採用されたのは明治6年1月1日(1873年1月1日)です。同じ表の、2025年10月時点として書かれた備考には、明治の元号が始まった1868年10月23日より前の日付はブラウザで期待どおりに動かないこと、1873年1月1日より前の日付の扱いは提案の段階で検討されていることが書かれています。

検証環境の Chromium では、1872年12月31日は「明治5年12月31日」、1868年10月22日は「慶応4年10月22日」と表示されました。どちらも、西暦の月日に元号を付けた表示です。この部品では、暦が採用される前の範囲を避け、年の下限を1873年にしました。

全角の数字や「㋿」は、normalize で半角にそろえる

入力欄には、「令和8年」のように全角の数字を打つ人もいます。convert は最初に normalize('NFKC') をかけてから読みます。MDN の normalize() の説明にあるとおり、NFKC は互換等価な文字を同じ形にそろえます。検証環境の Node.js では、全角の「8」「R」は半角の「8」「R」に、1文字の「㋿」「㍻」は「令和」「平成」の2文字になりました。

そのあと空白を取り除き、英字を大文字にしています。検証環境では、次の入力がすべて同じ結果になりました。

入力 表示
令和8年 2026年
令和8年 2026年
R8 2026年
r8 2026年
㋿8 2026年
令和 8 年(前後と途中に半角の空白) 2026年

西暦のほうも、「2026」「2026年」「2026」のどれでも「令和8年」になりました。

入力欄の部品にまとめる

tsx
import { useId, useState } from 'react';
import { convert } from './wareki';

export function WarekiInput({ defaultValue = '' }: { defaultValue?: string }) {
  const id = useId();
  const [text, setText] = useState(defaultValue);
  const result = convert(text);

  return (
    <div className="wareki">
      <label htmlFor={id}>西暦または和暦の年</label>
      <input
        id={id}
        type="text"
        value={text}
        placeholder="2026 / 令和8年 / R8"
        aria-describedby={`${id}-result`}
        aria-invalid={result.kind === 'error'}
        onChange={(e) => setText(e.target.value)}
      />
      <output id={`${id}-result`} htmlFor={id} aria-live="polite">
        {result.kind === 'ok' &&
          result.lines.map((line) => <p key={line} className="answer">{line}</p>)}
        {result.kind === 'hint' && <p>{result.message}</p>}
        {result.kind === 'error' && <p className="error">{result.message}</p>}
      </output>
    </div>
  );
}
css
.wareki {
  display: grid;
  gap: 8px;
  max-width: 360px;
}
.wareki input {
  font-size: 1.25rem;
  padding: 6px 8px;
}
.wareki output p {
  margin: 4px 0;
}
.wareki .answer {
  font-size: 1.5rem;
  color: #067a98;
}
.wareki .error {
  color: #d85878;
}

入力のたびに convert を呼び、結果を <output> に出しています。aria-describedby で入力欄と結果を結び、変換できない入力では aria-invalid を true にします。Playwright で「H32」や「abc」を入れると aria-invalid="true" になり、「2026」や空の欄では false でした。

打っている途中の入力は、エラーにしていません。元号だけ(「R」「令和」や、元号の1文字目の「令」など)なら「続けて年を入力してください」、3桁までの数字なら「西暦は4桁で入力してください」と出し、aria-invalid は false のままにします。打ち始めの1文字目からエラーの文が出ないように、この2つの形だけを分けました。

Playwright で「2」「0」「2」「6」と1文字ずつ打つと、3文字目までは「西暦は4桁で入力してください」で、aria-invalid は false でした。4文字目で「令和8年」に変わりました。「令和」「8」と打った場合も、「8」を打った時点で「2026年」に変わりました。なお、「999年」のように「年」を付けた3桁の年は、打ち終わった形とみなして、「1873年(明治6年)より前は扱いません」のエラーにしています。

日付を和暦で表示するだけなら Intl で足りる

西暦の日付を和暦で見せるだけなら、表を持たずに Intl.DateTimeFormat で書けます。MDN の Intl.DateTimeFormat() の説明にあるとおり、使う暦は calendar オプションか、ロケールに付ける ca の拡張キーで選べます。和暦は japanese なので、ロケールを ja-JP-u-ca-japanese にします。

ts
const d = new Date('2019-05-01T00:00:00Z');
new Intl.DateTimeFormat('ja-JP-u-ca-japanese', { dateStyle: 'long', timeZone: 'UTC' }).format(d);
// "令和元年5月1日"
new Intl.DateTimeFormat('ja-JP-u-ca-japanese', { era: 'narrow', year: 'numeric', timeZone: 'UTC' }).format(d);
// "R元年"

検証環境の Chromium でこのコードを実行すると、コメントのとおりの文字列になりました。同じ方法で、2026年11月5日は「令和8年11月5日」になりました。1年目は「元年」と表示され、era: 'narrow' にすると元号が「R」のような頭文字になりました。formatToParts() を使うと、元号は { type: 'era', value: '令和' }、年は { type: 'year', value: '元' } のように分かれて返ります。

Intl の和暦は、ブラウザが持っている元号のデータに従います。前に挙げた MDN の「対応している暦」の表には、元号が加わる暦のため、新しいエンジンでは将来の日付の年と元号が変わることがあると書かれています。表を自分で持つ wareki.ts は、表を書き足すまで変わりません。表示だけなら Intl に任せ、和暦の文字を読み取るときだけ表を使う、という分け方もできます。

​Posted by 86