TypeScript: 数字入力テキストインプット (Windows10のChrome107で動作する)

2022年12月6日(火)

環境

数字入力テキストインプット

重要事項

この実装はWindows10のChrome107で期待通りに動作する。

GitHubのリポジトリ

https://github.com/yvafdevnsk/typescript/tree/main/number-input-chrome

ファイルの一覧

number-input-chrome
    |- number-input-chrome.html
    |- number-input-chrome.css
    |- number-input-chrome.ts
    |- number-input-chrome.js (tsファイルから生成される)
    |- tsconfig.json
          

目標

テキストインプットのイベント

テキストインプットの入力値の状態遷移

  1. 画面に反映される前
  2. 画面に反映された後 (未確定)
  3. 確定 (Enterキーが押されたまたはフォーカスを失った)

イベントの発生順序

  1. paste, drop (デフォルトの動作をキャンセルすると以降のbeforeinputとinputは発生しない)
  2. beforeinput (新しい入力値が画面に反映される前)
  3. input (新しい入力値が画面に反映された後)
  4. change (入力値に変更があり、未確定の状態で、Enterキーが押されたまたはフォーカスを失ったとき)

Chromeの仕様

実装1: 入力可能とする文字の集合の定義

const allowMap: Map<string, string> = new Map([
    ["1","1"],["2","2"],["3","3"],["4","4"],["5","5"],
    ["6","6"],["7","7"],["8","8"],["9","9"],["0","0"]
]);
          

実装2: イベントリスナーの登録

const load: any = () => {
    init();
};
window.onload = load;

function init(): void {
    const numberInput: HTMLElement | null = document.getElementById("number-input");
    if (numberInput !== null) {
        numberInput.addEventListener("beforeinput", beforeinputEventListener);
        numberInput.addEventListener("input", inputEventListener);
        numberInput.addEventListener("compositionend", compositionEndEventListener);
        numberInput.addEventListener("paste", pasteEventListener);
        numberInput.addEventListener("drop", dropEventListener);
    }
}
          

実装3: beforeinputイベントの処理

function beforeinputEventListener(e: InputEvent): void {
    // IME入力が未確定の場合は何もしない。
    if (e.isComposing) {
        return;
    }

    // 入力値がある場合は受け入れ可能かの判断を行う。
    if ((e.data !== null) && (e.data.length > 0)) {
        // Array.from()でサロゲートペア単位での比較もできる。
        const dataList: string[] = Array.from(e.data);
        if (dataList.every((s) => allowMap.has(s))) {
            // 入力値のすべてがOKの場合は、入力値を受け入れる。
        }
        else if (dataList.some((s) => allowMap.has(s))) {
            // 入力値の何れかがNGの場合は、NGの文字を取り除く。
            // 取り除く処理はinputイベントで行う。
        }
        else {
            // 入力値のすべてがNGの場合は、入力をキャンセルする。
            e.preventDefault();
        }
    }
}
          

実装4: inputイベントの処理

function inputEventListener(e: InputEvent | Event): void {
    if (e instanceof InputEvent) {
        // IME入力が未確定の場合は何もしない。
        if (e.isComposing) {
            return;
        }

        // 入力値がある場合は受け入れ可能かの判断を行う。
        if ((e.data !== null) && (e.data.length > 0)) {
            // Array.from()でサロゲートペア単位での比較もできる。
            const dataList: string[] = Array.from(e.data);
            if (dataList.every((s) => allowMap.has(s))) {
                // 入力値のすべてがOKの場合は、入力値を受け入れる。
            }
            else {
                // 入力値の何れかがNGの場合は、NGの文字を取り除く。

                // inputイベントはキャンセルできないので
                // テキストインプットの内容から入力不可の文字を取り除く。

                // IMEで入力された場合はinputイベントがe.isComposing=trueで呼ばれた後にcompositionendイベントが呼ばれる。
                // IMEで入力された場合はbeforeinputイベントの処理はe.isComposing=trueになるのでスキップされる。
                // IMEで入力された場合はinputイベントの処理はe.isComposing=trueになるのでスキップされる。
                // IMEで入力された場合はcompositionendイベントで入力不可の文字を取り除く。
                const inputElement: HTMLInputElement = e.target as HTMLInputElement;
                inputElement.value = Array.from(inputElement.value)
                    .filter((s) => allowMap.has(s))
                    .reduce((previousValue, currentValue) => previousValue + currentValue, "");
            }
        }
    }
}
          

実装5: compositionendイベントの処理

function compositionEndEventListener(e: CompositionEvent): void {
    // 入力値がある場合は受け入れ可能かの判断を行う。
    if ((e.data !== null) && (e.data.length > 0)) {
        // Array.from()でサロゲートペア単位での比較もできる。
        const dataList: string[] = Array.from(e.data);
        if (dataList.every((s) => allowMap.has(s))) {
            // 入力値のすべてがOKの場合は、入力値を受け入れる。
        }
        else {
            // 入力値の何れかがNGの場合は、NGの文字を取り除く。

            // compositionendイベントはキャンセルできないので
            // テキストインプットの内容から入力不可の文字を取り除く。

            // IMEで入力された場合はinputイベントがe.isComposing=trueで呼ばれた後にcompositionendイベントが呼ばれる。
            // IMEで入力された場合はbeforeinputイベントの処理はe.isComposing=trueになるのでスキップされる。
            // IMEで入力された場合はinputイベントの処理はe.isComposing=trueになるのでスキップされる。
            // IMEで入力された場合はcompositionendイベントで入力不可の文字を取り除く。
            const inputElement: HTMLInputElement = e.target as HTMLInputElement;
            inputElement.value = Array.from(inputElement.value)
                .filter((s) => allowMap.has(s))
                .reduce((previousValue, currentValue) => previousValue + currentValue, "");
        }
    }
}
          

実装6: pasteイベントの処理

function pasteEventListener(e: ClipboardEvent): void {
    // デフォルトの動作をキャンセルする。
    e.preventDefault();

    // 貼り付けるデータを取得する。
    const pasteData: string | undefined = e.clipboardData?.getData("text");
    if (pasteData) {
        // 貼り付けるデータから入力不可の文字を取り除く。
        const pasteDataAllowed: string = Array.from(pasteData)
            .filter((s) => allowMap.has(s))
            .reduce((previousValue, currentValue) => previousValue + currentValue, "");

        if (pasteDataAllowed.length === 0) {
            return;
        }

        // テキストインプットのキャレットの位置に挿入する。
        const inputElement: HTMLInputElement = e.target as HTMLInputElement;
        if ((inputElement.selectionStart !== null) && (inputElement.selectionEnd !== null)) {
            // 貼り付ける前のキャレットの位置を保存する。
            const caretPositionBefore: number = inputElement.selectionStart;

            // 選択範囲ありの場合
            // 現在の選択範囲を削除したうえでキャレットの位置に挿入する。
            if (inputElement.selectionStart !== inputElement.selectionEnd) {
                inputElement.value = inputElement.value.slice(0, inputElement.selectionStart) + pasteDataAllowed + inputElement.value.slice(inputElement.selectionEnd);
            }
            // 選択範囲なしの場合
            // 現在のキャレットの位置に挿入する。
            else {
                inputElement.value = inputElement.value.slice(0, inputElement.selectionStart) + pasteDataAllowed + inputElement.value.slice(inputElement.selectionStart);
            }

            // 貼り付けたデータの後ろにキャレットの位置を移動する。
            inputElement.selectionStart = inputElement.selectionEnd = caretPositionBefore + [...pasteDataAllowed].length;
        }
    }
}
          

実装7: dropイベントの処理

function dropEventListener(e: DragEvent): void {
    // デフォルトの動作をキャンセルする。
    e.preventDefault();

    // ドロップするデータを取得する。
    const pasteData: string | undefined = e.dataTransfer?.getData("text");
    if (pasteData) {
        // ドロップするデータから入力不可の文字を取り除く。
        const pasteDataAllowed: string = Array.from(pasteData)
            .filter((s) => allowMap.has(s))
            .reduce((previousValue, currentValue) => previousValue + currentValue, "");

        if (pasteDataAllowed.length === 0) {
            return;
        }

        // テキストインプットのキャレットの位置に挿入する。
        const inputElement: HTMLInputElement = e.target as HTMLInputElement;
        if ((inputElement.selectionStart !== null) && (inputElement.selectionEnd !== null)) {
            // ドロップする前のキャレットの位置を保存する。
            // これはドラッグ操作開始前の状態になっている。
            const caretPositionBefore: number = inputElement.selectionStart;

            // 選択範囲ありの場合
            // 現在の選択範囲を削除したうえでキャレットの位置に挿入する。
            if (inputElement.selectionStart !== inputElement.selectionEnd) {
                inputElement.value = inputElement.value.slice(0, inputElement.selectionStart) + pasteDataAllowed + inputElement.value.slice(inputElement.selectionEnd);
            }
            // 選択範囲なしの場合
            // 現在のキャレットの位置に挿入する。
            else {
                inputElement.value = inputElement.value.slice(0, inputElement.selectionStart) + pasteDataAllowed + inputElement.value.slice(inputElement.selectionStart);
            }

            // ドロップしたデータの後ろにキャレットの位置を移動する。
            inputElement.selectionStart = inputElement.selectionEnd = caretPositionBefore + [...pasteDataAllowed].length;
        }
    }
}
          

ビルドする

$ npx tsc --target es2015 number-input-chrome.ts
        

確認する

サンプルページを表示する

参考情報