環境
- Windows 10 Pro 22H2
- Oracle VM VirtualBox 7.0.4 (2022/11/18)
- Debian 11: 11.5 (2022/09/10)
- TypeScript 4.9.3
数字入力テキストインプット
重要事項
この実装は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,4,5,6,7,8,9,0 のみ入力可能なテキストインプットを実装する。
- IME入力と貼り付けおよびドロップでも入力制限を行う。
テキストインプットのイベント
- beforeinput
- The DOM beforeinput event fires when the value of an <input>, or <textarea> element is about to be modified.
- Event type: InputEvent
- Cancelable: Yes
- Default Action: Update the DOM element
- input
- The input event fires when the value of an <input>, <select>, or <textarea> element has been changed.
- The input event is fired every time the value of the element changes.
- Event type: InputEvent (For <textarea> and <input> elements that accept text input (type=text, type=tel, etc.), the interface is InputEvent)
- Cancelable: No
- change
- when an alteration to the element's value is committed by the user.
- when the element loses focus after its value was changed, but not committed (e.g., after editing the value of <textarea> or <input type="text">).
- Event type: Event
- compositionstart
- The compositionstart event is fired when a text composition system such as an input method editor starts a new composition session.
- Event type: CompositionEvent
- Cancelable: Yes
Some IMEs do not support cancelling an in-progress composition session (e.g., such as GTK which doesn't presently have such an API). In these cases, calling preventDefault() will not stop this event's default action.
- Default action: Start a new composition session when a text composition system is enabled.
- compositionupdate
- The compositionupdate event is fired when a new character is received in the context of a text composition session controlled by a text composition system such as an input method editor.
- Event type: CompositionEvent
- Cancelable: No
- compositionend
- The compositionend event is fired when a text composition system such as an input method editor completes or cancels the current composition session.
- Event type: CompositionEvent
- Cancelable: No
- paste
- The paste event is fired when the user has initiated a "paste" action through the browser's user interface.
- Event type: ClipboardEvent
- Cancelable: Yes
- drop
- The drop event is fired when an element or text selection is dropped on a valid drop target.
- Event type: DragEvent
- Cancelable: Yes
- HTML Drag and Drop API
テキストインプットの入力値の状態遷移
- 画面に反映される前
- 画面に反映された後 (未確定)
- 確定 (Enterキーが押されたまたはフォーカスを失った)
イベントの発生順序
- paste, drop (デフォルトの動作をキャンセルすると以降のbeforeinputとinputは発生しない)
- beforeinput (新しい入力値が画面に反映される前)
- input (新しい入力値が画面に反映された後)
- change (入力値に変更があり、未確定の状態で、Enterキーが押されたまたはフォーカスを失ったとき)
Chromeの仕様
- IME入力の確定はinputイベントがisComposing=trueの後にcompositionendイベントが発行される。IME入力の確定はcompositionendイベントで処理する。
- 貼り付けではinputイベントのInputEvent.data(string)はnullになっている。貼り付けはpasteイベントで処理する。
- ドロップではinputイベントのInputEvent.data(string)はnullになっている。ドロップはdropイベントで処理する。
実装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
確認する
参考情報
- <input type="text"> - Web APIs | MDN
- HTMLElement: change event - Web APIs | MDN
- HTMLElement: input event - Web APIs | MDN
- HTMLElement: beforeinput event - Web APIs | MDN
- Element: compositionstart event - Web APIs | MDN
- Element: compositionupdate event - Web APIs | MDN
- Element: compositionend event - Web APIs | MDN
- Element: paste event - Web APIs | MDN
- HTML Drag and Drop API - Web APIs | MDN
- InputEvent - Web APIs | MDN
- Event - Web APIs | MDN
- Falsy | MDN
- String length | MDN
- GlobalEventHandlers.onload - Web APIs | MDN
- Introduction to events - Learn web development | MDN