全角数字を半角数字へ変換する JavaScript:IME入力で文字が重複しない版

| Web Design | 2 Reads

ASP.NET WebForms や普通の HTML の TextBox で、ユーザーに数字を入力してもらう画面はよくあります。
ただ、日本語入力環境では 123ー123.2 のように、全角数字・全角小数点・長音符のマイナスが入力されることがあります。

この記事では、全角数字を半角数字へ変換しつつ、IME変換中に input イベントで value を書き換えて文字が重複する問題を避ける JavaScript を紹介します。

結論

ポイントは、compositionstart から compositionend までの間は、入力欄の value を変更しないことです。

IME合成中に el.value = ... のような処理をしてしまうと、環境によっては次の入力時に文字が重複することがあります。たとえば、入力したつもりは 123 なのに、途中で 112112123 のような不自然な値になるケースです。

実際に試せるサンプル

下の入力欄はそのまま試せます。全角数字や全角小数点を入力してみてください。

1. 変換だけ:全角数字・全角小数点・マイナス系文字
期待値:ー123.2-123.2
2. 整数 0~999
期待値:1231231000999
3. 小数 0~999.9(小数1位)
期待値:123.2123.2123.23123.2
4. 小数 -999.9~999.9(小数1位)
期待値:ー123.2-123.2ー1000-999.9

使い方

入力欄に用途別の class を付けます。

<input type="text" class="ig-normalize-only">
<input type="text" class="ig-int0to999">
<input type="text" class="ig-dec0to999-1">
<input type="text" class="ig-dec-neg999_9">

ASP.NET WebForms の TextBox なら、次のように CssClass に指定します。

<asp:TextBox ID="txtNumber1" runat="server" CssClass="ig-int0to999" />

<asp:TextBox ID="txtRate1" runat="server" CssClass="ig-dec0to999-1" />

<asp:TextBox ID="txtAmount1" runat="server" CssClass="ig-dec-neg999_9" />

class の意味

  • ig-normalize-only:全角数字・全角小数点・マイナス系文字を半角へ変換するだけ
  • ig-int0to999:整数のみ。範囲は 0~999
  • ig-dec0to999-1:小数1位まで。範囲は 0~999.9
  • ig-dec-neg999_9:小数1位まで。範囲は -999.9~999.9

なぜ keydown だけでは足りないのか

半角数字だけを許可したい場合、keydown で数字以外を止める実装をよく見ます。

しかし、日本語IMEで全角数字を入力する場合、キーを押した時点ではまだ変換中です。ブラウザ上では keyCode229 になったり、keyProcess になったりします。

そのため、keydown だけで完全に制御しようとすると、IME入力と相性が悪くなります。

このサンプルでは、通常の半角入力は keydown である程度制限し、IME経由の入力は compositionendinput で安全に整形します。

変換対象

このサンプルでは、次の文字を変換対象にしています。

  • 09
  • .
  • -

JavaScript

以下の JavaScript をページに1回だけ読み込めば、同じ class を付けた複数の TextBox にまとめて適用できます。

(function () {
    "use strict";

    function isNavKey(key) {
        return (
            key === "Backspace" ||
            key === "Delete" ||
            key === "Tab" ||
            key === "Enter" ||
            key === "Escape" ||
            key === "ArrowLeft" ||
            key === "ArrowRight" ||
            key === "ArrowUp" ||
            key === "ArrowDown" ||
            key === "Home" ||
            key === "End"
        );
    }

    function isCtrlCombo(e) {
        return e.ctrlKey || e.metaKey;
    }

    // 全角数字・全角小数点・マイナス系文字を半角へ変換する
    function normalizeNumberText(value) {
        return String(value || "").replace(/[0-9.ー-−]/g, function (ch) {
            if (ch >= "0" && ch <= "9") {
                return String.fromCharCode(ch.charCodeAt(0) - 0xFEE0);
            }

            if (ch === ".") {
                return ".";
            }

            if (ch === "ー" || ch === "-" || ch === "−") {
                return "-";
            }

            return ch;
        });
    }

    // IME合成中は value を書き換えない
    function bindImeSafeInput(el, handler) {
        el._igIsComposing = false;

        el.addEventListener("compositionstart", function () {
            el._igIsComposing = true;
        });

        el.addEventListener("compositionend", function () {
            el._igIsComposing = false;
            handler();
        });

        el.addEventListener("input", function (e) {
            if (e.isComposing || el._igIsComposing) {
                return;
            }

            handler();
        });
    }

    // 1. 変換だけ
    function bindNormalizeOnly(el) {
        if (el._igBoundNormalizeOnly) return;
        el._igBoundNormalizeOnly = true;

        bindImeSafeInput(el, function () {
            el.value = normalizeNumberText(el.value);
        });
    }

    // 2. 整数 0~999
    function bindInt0to999(el) {
        if (el._igBoundInt0to999) return;
        el._igBoundInt0to999 = true;

        el.addEventListener("keydown", function (e) {
            if (isNavKey(e.key) || isCtrlCombo(e)) return;

            // IME合成中は止めない
            if (e.isComposing || e.key === "Process" || e.keyCode === 229) return;

            if (!/^[0-9]$/.test(e.key)) {
                e.preventDefault();
            }
        });

        bindImeSafeInput(el, function () {
            var v = normalizeNumberText(el.value);

            v = v.replace(/[^\d]/g, "");
            v = v.replace(/^0+(?=\d)/, "");

            if (v === "") {
                el.value = "";
                return;
            }

            var n = parseInt(v, 10);

            if (isNaN(n)) {
                el.value = "";
            } else if (n > 999) {
                el.value = "999";
            } else {
                el.value = String(n);
            }
        });
    }

    // 3. 小数 0~999.9(小数1位)
    function bindDecimal0to999_1(el) {
        if (el._igBoundDecimal0to999_1) return;
        el._igBoundDecimal0to999_1 = true;

        el.addEventListener("keydown", function (e) {
            if (isNavKey(e.key) || isCtrlCombo(e)) return;
            if (e.isComposing || e.key === "Process" || e.keyCode === 229) return;

            if (!/^[0-9.]$/.test(e.key)) {
                e.preventDefault();
                return;
            }

            if (e.key === "." && el.value.indexOf(".") >= 0) {
                e.preventDefault();
            }
        });

        bindImeSafeInput(el, function () {
            var v = normalizeNumberText(el.value);

            v = v.replace(/[^0-9.]/g, "");

            var parts = v.split(".");
            if (parts.length > 2) {
                v = parts[0] + "." + parts.slice(1).join("");
            }

            var idx = v.indexOf(".");
            if (idx >= 0) {
                var a = v.substring(0, idx);
                var b = v.substring(idx + 1).substring(0, 1);

                a = a.replace(/^0+(?=\d)/, "");
                if (a === "") a = "0";

                if (parseInt(a, 10) > 999) {
                    a = "999";
                }

                v = a + "." + b;
            } else {
                v = v.replace(/^0+(?=\d)/, "");

                if (v !== "") {
                    var n = parseInt(v, 10);
                    if (!isNaN(n) && n > 999) {
                        v = "999";
                    }
                }
            }

            el.value = v;
        });
    }

    // 4. 小数 -999.9~999.9(小数1位)
    function bindDecimalNeg999_9to999_9_1(el) {
        if (el._igBoundDecimalNeg999_9) return;
        el._igBoundDecimalNeg999_9 = true;

        el.addEventListener("keydown", function (e) {
            if (isNavKey(e.key) || isCtrlCombo(e)) return;
            if (e.isComposing || e.key === "Process" || e.keyCode === 229) return;

            if (!/^[0-9.-]$/.test(e.key)) {
                e.preventDefault();
                return;
            }

            if (e.key === "." && el.value.indexOf(".") >= 0) {
                e.preventDefault();
                return;
            }

            if (e.key === "-") {
                if (el.value.indexOf("-") >= 0 || el.selectionStart !== 0) {
                    e.preventDefault();
                }
            }
        });

        bindImeSafeInput(el, function () {
            var v = normalizeNumberText(el.value);

            v = v.replace(/[^0-9.-]/g, "");

            var neg = v.indexOf("-") >= 0;
            v = v.replace(/-/g, "");
            if (neg) {
                v = "-" + v;
            }

            var sign = "";
            if (v.indexOf("-") === 0) {
                sign = "-";
                v = v.substring(1);
            }

            var parts = v.split(".");
            if (parts.length > 2) {
                v = parts[0] + "." + parts.slice(1).join("");
            }

            var idx = v.indexOf(".");
            if (idx >= 0) {
                var a = v.substring(0, idx);
                var b = v.substring(idx + 1).substring(0, 1);
                v = a + "." + b;
            }

            v = sign + v;

            // 入力途中の "-" は残す
            if (v === "" || v === "-") {
                el.value = v;
                return;
            }

            if (v === "." || v === "-.") {
                el.value = "";
                return;
            }

            var num = parseFloat(v);

            if (isNaN(num)) {
                el.value = "";
                return;
            }

            if (num > 999.9) num = 999.9;
            if (num < -999.9) num = -999.9;

            var s = String(num);

            if (s.indexOf(".") >= 0) {
                var p = s.split(".");
                s = p[0] + "." + (p[1] || "").substring(0, 1);
            }

            el.value = s;
        });
    }

    function bindAllNumberInputs() {
        var i;
        var list;

        list = document.querySelectorAll(".ig-normalize-only");
        for (i = 0; i < list.length; i++) bindNormalizeOnly(list[i]);

        list = document.querySelectorAll(".ig-int0to999");
        for (i = 0; i < list.length; i++) bindInt0to999(list[i]);

        list = document.querySelectorAll(".ig-dec0to999-1");
        for (i = 0; i < list.length; i++) bindDecimal0to999_1(list[i]);

        list = document.querySelectorAll(".ig-dec-neg999_9");
        for (i = 0; i < list.length; i++) bindDecimalNeg999_9to999_9_1(list[i]);
    }

    if (document.readyState === "loading") {
        document.addEventListener("DOMContentLoaded", bindAllNumberInputs);
    } else {
        bindAllNumberInputs();
    }
})();

注意点

  • この処理はクライアント側の入力補助です。DB登録前やサーバー側のチェックは別途必要です。
  • 入力途中の見た目を優先したい場合は、input ではなく blur 時だけ整形する形に変えることもできます。

まとめ

全角数字を半角数字へ変換する処理自体は簡単ですが、日本語IMEを考慮する場合は compositionstartcompositionendinput の扱いが重要です。

特に、IME合成中に入力欄の値を書き換えないこと。これを守るだけで、文字の重複や変換中の違和感をかなり避けられます。

This article was last edited at