Skip to content

数値入力のスクロール問題

<input type="number" /> において、form にフォーカスした状態でスクロールを行うと数値が動く問題がしばしば発生する。
ブラウザによって多少の差分はあるものの、意図しない形で値が変わってしまうことを防ぐ方法について考えた。
筆者は社内むけの UI ライブラリを開発してるので、前提として、UI ライブラリの開発者目線での話をしているが、同様のことはユーザー側でも起こりうるので読者がどのような対策をしてるのかを聞いてみたさがある。

解決案

考えられる非常に簡易的な解決策を 3 つほど出してみた。(すなわち登場するコードも概要であり、細かいことは省いている)
最初の 1 つは type="number" として扱うもの、もう 2 つは type="text" にして number のような振る舞いをするような処理を挟む方向。

WARNING

type="text" として扱う際には、入力が数値かどうかを判定する処理を自前で用意しないといけない。
1.2.3 のような数値として不適切なものを弾く、1e-10 のような形式のものを許可する、等々考えることがいくつかある。

onWheel + blur でスクロール時に focus を外す

これは、MUI の [TextField] How to prevent mousewheel in input of type number?#7960 のコメント で提示されていた解決策で、wheel イベント が呼ばれた時に input のフォーカスを外すことによってそもそもスクロールをできないようにしようというもの。
下の方の コメント で「super nice solution」と称賛されている。

// スクロールされたら選択中の input から focus を外してスクロールできないようにする
const onWheel = (event: WheelEvent<HTMLInputElement>) => {
  event.currentTarget.blur();
};

return <Input type="number" onWheel={onWheel} />;
1
2
3
4
5
6

数字入力ではなく <input type="text" /> を使いユーザーからの入力の際に onCompositionEnd で置換処理を挟む

compositionend イベントで IME の入力状態を監視して、入力が確定したらバリデーションを行う方法。
ここでは type="text" として扱い、バリデーションのよう 全角/半角 や 数字/文字列 のバリデーションをかけながらやっていくような印象になる。
しかし無駄な再レンダリングが走るという issue があるっぽい。(随分長いこと開かれていて、多くの場所から参照されている)

const onCompotisionEnd = (event: CompositionEvent<HTMLInputElement>) => {
    // 半角の数字のみ許可して値をセット
}

return <Input type="text" onChange={onChange} onCompositionEnd={onCompositionEnd}>
1
2
3
4
5

また、全角文字が入力されたときに期待する文字列に置換処理を行う方法も考えられる。

const onCompotisionEnd = (event: CompositionEvent<HTMLInputElement>) => {
    // 置換処理を行い値をセット
}

return <Input type="text" onChange={onChange} onCompositionEnd={onCompositionEnd}>
1
2
3
4
5

この方法は非常に良いのだが、どこまで値を許可するか、どのような置換処理を挟むのかを非常に多く考慮しないといけないため、

数字入力ではなく <input type="text" /> を使い、onKeyDown + onChange を使ってユーザーがそもそもキーを押せないようにする

これはそもそも許可したキー以外を押させないようにする方法。onKeyDown で入力を監視する、onChange は入力が数値ではなかったときに早期 return を行う。
1 つ上の onCompositionEnd を利用する方法と同様、type="text" を用いる。

この方法では、入力を直接監視するのでそもそも入力させないということが可能だが、ただ数字を許可すればいいだけではなく、小数点が入ってきたときにそれが数値かどうかを判定するような処理を挟む必要があり、少々複雑になる。
また、ユーザーが onChange を引数に渡さなかった場合の対処として、form の中の値を ref を用いて管理する必要があるなど、状態管理が複雑になる傾向がある。

const inputRef = React.useRef<HTMLInputElement>(null);
const mergeedRef = useMergeRefs(ref, inputRef);

const onChange = (event: ChangeEvent<HTMLInputElement>) => {
  const value = event.target.value;
  // onKeyDown の引数のみからでは小数点判定はできないので onChange で数値確認して弾く
  if (!isNaN(Number(value))) {
    return;
  }
}

const onKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
  // whitespace だったら入力しない、...etc
  if (event.key === " ") {
    event.preventDefault();
  }
}

return <Input type="text" onChange={onChange} onKeyDown={onKeyDown}>;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

(参考): MUI の解決方法

MUI では Add NumberInput component #19154 という issue が立てられ、議論がされている。
demo の storybook も存在していて、ここでの挙動を見ると今回メモとして記事にしてるスクロールも防止されている上に、数字以外の入力はできないようになっている。 (exponential の文脈で使われる e の入力には対応してない模様)

あまり深くは読んでいないが、実際にコードを見てみると、useNumberInput という hooks を既存の MUI のコンポーネントに適用させて状態を管理している。

html 的にはどうなのか

そもそも、<input type="number" /> について、html 的な見解としてはどうなのかについて見てみる。
そもそも、number の input はユースケースが限定されていて、例えば電話番号やクレジットカードなどの数字に見えて実は数値としての意味を持たないものに関しては使わないことは広く知られているが、WHATWG の input[type=number] には、以下のような文言が書かれている。

A simple way of determining whether to use is to consider whether it would make sense for the input control to have a spinbox interface (eg with "up" and "down" arrows).

つまり、数値のインクリメント/デクリメントをする上下ボタンを使うか否かを一つの判断基準にするべきとのこと。これを基準にすると、type="number" な input は多くの場合選ばれなくなると思われる。

最後に

あまり上手く纏まっていないが、数値入力周りの抑制やバリデーションは想像以上に大変だということがわかった。
ここにさらにユーザーが定義するバリデーションが加わるとなるとさらに考えることは増えるだろうなと感じる。
みなさんは個人開発や組織での開発において、ここらへんをどのように対処してるのかについて聞きたい。