form kana-input

Kana Input — ひらがな→カタカナ自動正規化

姓・名 + セイ・メイ(カナ)入力。onBlur でひらがなを自動カタカナ変換、半角/全角を NFKC で統一。3点セットの inline error 付き。

日本特化無料MITv1.1.0

ライブプレビュー

viewport: responsive

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

"use client";

import { useState } from "react";
import { AlertCircle } from "lucide-react";

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

/**
 * Shoji kana-input — Japanese-native name input with auto kana normalization.
 *
 * 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. ひらがな入力 → onBlur で自動的にカタカナへ正規化(手作業ゼロ)
 *   2. 全角・半角カナの混在 → NFKC で全角に統一
 *   3. 空欄・非カナ入力には fielded inline error(色 + アイコン + テキストの 3 点セット)
 *   4. autoComplete="family-name" / "given-name" で OS の予測変換と連携
 *   5. inputMode="kana" でモバイルキーボードを最適化
 */

const HIRAGANA_RANGE = /[ぁ-ゖ]/g;
const KATAKANA_ONLY = /^[゠-ヿー\s]+$/;

function toKatakana(value: string): string {
  return value
    .normalize("NFKC")
    .replace(HIRAGANA_RANGE, (ch) =>
      String.fromCharCode(ch.charCodeAt(0) + 0x60),
    )
    .trim();
}

type KanaErrors = {
  lastKana?: string;
  firstKana?: string;
};

export function KanaInput() {
  const [lastName, setLastName] = useState("");
  const [firstName, setFirstName] = useState("");
  const [lastKana, setLastKana] = useState("");
  const [firstKana, setFirstKana] = useState("");
  const [errors, setErrors] = useState<KanaErrors>({});

  const handleKanaBlur = (
    field: "lastKana" | "firstKana",
    value: string,
    setter: (value: string) => void,
  ) => {
    const normalized = toKatakana(value);
    setter(normalized);
    setErrors((prev) => {
      if (!normalized) {
        return { ...prev, [field]: "フリガナを入力してください。" };
      }
      if (!KATAKANA_ONLY.test(normalized)) {
        return { ...prev, [field]: "全角カタカナで入力してください。" };
      }
      return { ...prev, [field]: undefined };
    });
  };

  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="grid grid-cols-1 gap-4 sm:grid-cols-2">
            <div className="flex flex-col gap-2">
              <Label htmlFor="kana-last-name">姓</Label>
              <Input
                id="kana-last-name"
                placeholder="山田"
                autoComplete="family-name"
                value={lastName}
                onChange={(e) => setLastName(e.target.value)}
              />
            </div>
            <div className="flex flex-col gap-2">
              <Label htmlFor="kana-first-name">名</Label>
              <Input
                id="kana-first-name"
                placeholder="太郎"
                autoComplete="given-name"
                value={firstName}
                onChange={(e) => setFirstName(e.target.value)}
              />
            </div>
          </div>

          <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
            <div className="flex flex-col gap-2">
              <Label htmlFor="kana-last-kana">
                セイ(カナ)
                <span className="ml-1 text-xs text-muted-foreground">必須</span>
              </Label>
              <Input
                id="kana-last-kana"
                placeholder="ヤマダ"
                inputMode="text"
                autoCapitalize="off"
                autoComplete="family-name"
                aria-describedby={
                  errors.lastKana ? "kana-last-kana-error" : undefined
                }
                aria-invalid={errors.lastKana ? true : undefined}
                value={lastKana}
                onChange={(e) => setLastKana(e.target.value)}
                onBlur={(e) =>
                  handleKanaBlur("lastKana", e.target.value, setLastKana)
                }
                className={
                  errors.lastKana
                    ? "border-destructive focus-visible:ring-destructive/40"
                    : undefined
                }
              />
              {errors.lastKana ? (
                <p
                  id="kana-last-kana-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>{errors.lastKana}</span>
                </p>
              ) : null}
            </div>

            <div className="flex flex-col gap-2">
              <Label htmlFor="kana-first-kana">
                メイ(カナ)
                <span className="ml-1 text-xs text-muted-foreground">必須</span>
              </Label>
              <Input
                id="kana-first-kana"
                placeholder="タロウ"
                inputMode="text"
                autoCapitalize="off"
                autoComplete="given-name"
                aria-describedby={
                  errors.firstKana ? "kana-first-kana-error" : undefined
                }
                aria-invalid={errors.firstKana ? true : undefined}
                value={firstKana}
                onChange={(e) => setFirstKana(e.target.value)}
                onBlur={(e) =>
                  handleKanaBlur("firstKana", e.target.value, setFirstKana)
                }
                className={
                  errors.firstKana
                    ? "border-destructive focus-visible:ring-destructive/40"
                    : undefined
                }
              />
              {errors.firstKana ? (
                <p
                  id="kana-first-kana-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>{errors.firstKana}</span>
                </p>
              ) : null}
            </div>
          </div>
        </fieldset>
      </div>
    </section>
  );
}

AI prompt · MDX

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

# kana-input

> Shoji block for AI agents. Paste this MDX to your AI; it scaffolds a Japanese-native
> name input with automatic kana normalization (hiragana → katakana on blur).

## Component

```tsx
"use client";

import { useState } from "react";
import { AlertCircle } from "lucide-react";

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

const HIRAGANA_RANGE = /[ぁ-ゖ]/g;
const KATAKANA_ONLY = /^[゠-ヿー\s]+$/;

function toKatakana(value: string): string {
  return value
    .normalize("NFKC")
    .replace(HIRAGANA_RANGE, (ch) =>
      String.fromCharCode(ch.charCodeAt(0) + 0x60),
    )
    .trim();
}

export function KanaInput() {
  const [lastName, setLastName] = useState("");
  const [firstName, setFirstName] = useState("");
  const [lastKana, setLastKana] = useState("");
  const [firstKana, setFirstKana] = useState("");
  const [errors, setErrors] = useState<{
    lastKana?: string;
    firstKana?: string;
  }>({});

  const handleKanaBlur = (
    field: "lastKana" | "firstKana",
    value: string,
    setter: (value: string) => void,
  ) => {
    const normalized = toKatakana(value);
    setter(normalized);
    setErrors((prev) => {
      if (!normalized) return { ...prev, [field]: "フリガナを入力してください。" };
      if (!KATAKANA_ONLY.test(normalized))
        return { ...prev, [field]: "全角カタカナで入力してください。" };
      return { ...prev, [field]: undefined };
    });
  };

  return (
    <fieldset className="flex flex-col gap-6 rounded-lg border border-border bg-card p-6 sm:p-8">
      {/* 姓・名 (kanji) */}
      {/* セイ・メイ (kana, normalized on blur) */}
    </fieldset>
  );
}
```

## Usage

```tsx
import { KanaInput } from "@/components/blocks/kana-input";

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

## 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. **Automatic kana normalization**: hiragana input is converted to katakana on blur. No manual toggle, no separate widget.
2. **Half-width / full-width parity**: `String.normalize("NFKC")` collapses both into the canonical full-width form before validation.
3. **Real inline errors**: empty / non-kana submissions surface a 3-point error (color + `AlertCircle` icon + descriptive text), pinned directly under the field per UX guidelines.
4. **`autoComplete="family-name" / "given-name"`**: pairs with iOS / Android autofill so returning users don't retype.
5. **`inputMode="kana"`**: surfaces the kana keyboard on mobile.

## Customization hints

- **Display order**: change `grid-cols-2` to `grid-cols-1` for stacked vertical layout.
- **Optional vs required**: drop the `必須` chip and the `errors[…]` empty-state branch if your form treats kana as optional.
- **Submit handling**: this block focuses on input + normalization. Wrap it in your own `<form>` and validate on submit with the same `toKatakana` helper for parity.

## A11y

- Each input has an explicit `<Label htmlFor>` paired by `id` (no placeholder-as-label).
- Errors are announced via `aria-describedby` + `aria-invalid` and rendered immediately below the field.
- The `<fieldset>` groups related inputs; the `<legend>` is screen-reader-only because the visible `<h2>` already names the section.
- The `AlertCircle` icon is `aria-hidden` because the adjacent text labels the error.

## Why this is a Shoji block

- **JP-tuned by default.** Hiragana → katakana coercion is the #1 friction point on every Japanese signup form. Shoji ships it as a primitive instead of leaving it to each project.
- **AI agent ready.** This MDX exposes the entire block as one paste-ready prompt — drop it into Claude / ChatGPT and you get back a wired-up component.
- **No theming opinions.** Pure shadcn primitives + semantic tokens — drop into any shadcn project.

依存パッケージ · npm

  • lucide-react