Reactで、ドラッグで数値を増減できる入力欄を作る方法

​​React​Playwright,Pointer Events,TypeScript,フォーム

ラベルを左右にドラッグすると数値が増減する入力欄は、ラベルに onPointerDown と onPointerMove を付け、押した位置からの距離を「何段階ぶんか」に直して値に足せば作れます。押した瞬間に setPointerCapture を呼んでおくと、ポインターがラベルの外に出ても値が変わり続けます。

数値を打つ欄は <input type="number"> のまま残すので、キーボードでの数字の入力や ↑ キーでの増減も、これまでどおり使えます。幅と不透明度を変える2つの欄で、ドラッグ、Shift を押したときの10倍の動き、小数の丸め、クリックとドラッグの区別を確かめました。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、Playwright の page.mouse でマウスを操作しました。タッチ操作、ペン、Firefox と Safari、スマートフォンのブラウザ、スクリーンリーダーでの読み上げは試していません。

「幅」のラベルを右へ 90px ドラッグすると、入力欄の数字が 120 から 165 まで増え、下の水色の四角が横に伸びる。「不透明度」のラベルを右へ 80px ドラッグすると 0.5 から 0.9 になり、四角が濃くなる。Shift を押しながら「幅」を右へ 12px ドラッグすると、165 から 225 まで60増える。下の行には、幅と不透明度の今の値が出ている
3回のドラッグで、入力欄の数字と四角の幅・濃さが変わる様子。数字が変わったコマだけ、その数字がピンクになる(カーソル、押している間の輪、Shift の表示、操作の説明と回数の表示は GIF 用に重ねたもの)

ラベルをつまみにして、押した位置からの距離で値を決める

コンポーネントの全体は次のとおりです。値は親が state で持ち、value と onChange で受け渡します。pixelsPerStep は、何 px 動かすと値が step 1つ分変わるかを決める値です。

tsx
import { useId, useRef, useState } from 'react';
import { roundToStep } from './roundToStep';

type Props = {
  label: string;
  value: number;
  onChange: (value: number) => void;
  min?: number;
  max?: number;
  step?: number;
  pixelsPerStep?: number; // 何px動かすと step 1つ分変わるか
};

type Drag = {
  pointerId: number;
  startX: number;
  startValue: number;
  lastX: number;
  factor: number; // 1、Shift で 10
  moved: boolean;
};

export function DragNumberInput({
  label, value, onChange, min = -Infinity, max = Infinity, step = 1, pixelsPerStep = 2,
}: Props) {
  const id = useId();
  const drag = useRef<Drag | null>(null);
  const [draft, setDraft] = useState<string | null>(null); // 打っている途中の文字列

  function onPointerDown(e: React.PointerEvent<HTMLLabelElement>) {
    if (e.button !== 0) return;
    e.currentTarget.setPointerCapture(e.pointerId);
    drag.current = { pointerId: e.pointerId, startX: e.clientX, startValue: value, lastX: e.clientX, factor: 1, moved: false };
  }

  function onPointerMove(e: React.PointerEvent<HTMLLabelElement>) {
    const d = drag.current;
    if (!d || d.pointerId !== e.pointerId) return;
    const factor = e.shiftKey ? 10 : 1;
    if (factor !== d.factor) {
      // Shift を押した・離したときは、1つ前の位置と今の値から数え直す
      d.startX = d.lastX;
      d.startValue = value;
      d.factor = factor;
    }
    d.lastX = e.clientX;
    const dx = e.clientX - d.startX;
    if (Math.abs(dx) >= 3) d.moved = true;
    if (!d.moved) return;
    const steps = Math.trunc(dx / pixelsPerStep);
    const next = roundToStep(d.startValue + steps * step * factor, min, max, step);
    if (next !== value) onChange(next);
  }

  function endDrag() {
    // moved は、この後の click で使うので残しておく
    if (drag.current) drag.current.pointerId = -1;
  }

  function onClick(e: React.MouseEvent<HTMLLabelElement>) {
    // ドラッグの後の click では、入力欄にフォーカスを移さない
    if (drag.current?.moved) e.preventDefault();
    drag.current = null;
  }

  function commit(text: string) {
    const n = Number(text);
    if (text.trim() !== '' && Number.isFinite(n)) onChange(roundToStep(n, min, max, step));
    setDraft(null);
  }

  return (
    <div className="drag-number">
      <label
        htmlFor={id}
        onPointerDown={onPointerDown}
        onPointerMove={onPointerMove}
        onPointerUp={endDrag}
        onPointerCancel={endDrag}
        onClick={onClick}
      >
        {label}
      </label>
      <input
        id={id}
        type="number"
        value={draft ?? String(value)}
        min={Number.isFinite(min) ? min : undefined}
        max={Number.isFinite(max) ? max : undefined}
        step={step}
        onChange={(e) => {
          const text = e.target.value;
          setDraft(text);
          const n = Number(text);
          // 範囲の中の数なら、打っている途中でもすぐに反映する
          if (text !== '' && n >= min && n <= max) onChange(n);
        }}
        onBlur={(e) => commit(e.target.value)}
        onKeyDown={(e) => {
          if (e.key === 'Enter') commit(e.currentTarget.value);
        }}
      />
    </div>
  );
}

値は、動くたびに少しずつ足していくのではなく、「押したときの値 + 押した位置からの距離ぶんの段階」で毎回計算し直します。こうしておくと、値は押した位置からの距離だけで決まり、途中で pointermove が何回届いたかには左右されません。距離を段階に直すときは Math.trunc で端数を切り捨てるので、2px ごとに1段階ずつ変わります。

検証には、次の画面を使いました。

tsx
<DragNumberInput label="幅" value={width} onChange={setWidth} min={20} max={300} step={1} />
<DragNumberInput label="不透明度" value={opacity} onChange={setOpacity} min={0} max={1} step={0.01} />
<div className="preview" style={{ width, opacity }} />
<p className="state">幅 {width} / 不透明度 {opacity}</p>

幅の初期値は 120 です。「幅」のラベルを押して左右に動かした結果は、次のとおりでした。同じ画面で、続けて3回ドラッグしています。

ドラッグ 幅
右へ 40px 140
左へ 100px 90
右へ 400px 290

2回目以降は、前のドラッグの結果から始まります。120 → 140 → 90 → 290 と、ドラッグした距離の半分ずつ変わっています。

ラベルの見た目は CSS で決めます。

css
.drag-number label {
  width: 7em;
  cursor: ew-resize;
  user-select: none;
  touch-action: none;
  border-bottom: 1px dashed #067a98;
  color: #067a98;
}

cursor: ew-resize は、ラベルの上でのカーソルを左右の矢印の形にして、ドラッグできることを知らせるための指定です。user-select: none は、ドラッグ中にラベルの文字が選択されないようにするためです。touch-action: none は、MDN によるとブラウザーがパンやズームのジェスチャーを扱わなくなる指定で、指でラベルを動かしたときにページがスクロールしないように付けています。

setPointerCapture で、ラベルの外まで追いかける

ラベルの幅は 112px しかないので、ドラッグするとすぐにポインターがラベルの外に出ます。MDN の setPointerCapture() のページにあるとおり、ポインターキャプチャを設定すると、キャプチャが解放されるまで、そのポインターのイベントはキャプチャした要素に届きます。解放は pointerup か pointercancel のときに自動で行われるので、自分で releasePointerCapture を呼ぶ必要はありません。

setPointerCapture の1行を消したものと比べました。どちらも、ラベルの中で右へ 30px 動かし、下へ 120px 外れてから、さらに右へ 200px 動かしています。

操作 setPointerCapture あり なし
ラベルの中で右へ 30px 135 135
ラベルの外で、さらに右へ 200px 235 135
ラベルの外でボタンを離す 235 135
ボタンを押さずにラベルの上に戻り、右へ 20px 235 130

setPointerCapture がないと、ラベルの外での動きが届かず、値は 135 で止まりました。さらに、ラベルの外で離した pointerup も届かないので、ドラッグが終わったことになりません。ボタンを押していないのにラベルの上でポインターを動かすと、値がまた変わりました。

Shift を押したら、その位置から10倍で数え直す

Shift を押している間は、1段階を step の10倍にします。押しているかどうかは、pointermove のイベントの shiftKey で見ています。

ここで、押した位置からの距離をそのまま10倍すると、Shift を押した瞬間に、それまで動かした距離のぶんまで10倍になって値が跳びます。そこで、Shift を押した・離したときは、1つ前の位置と今の値を新しい起点にして、そこから数え直しています。

ドラッグの途中で Shift を押し、また離したときの「幅」の値です。

操作 幅
Shift なしで右へ 20px 130
Shift を押して、右へ 1px 130
Shift を押したまま、さらに右へ 20px 230
Shift を離して、さらに右へ 20px 240

Shift を押した直後は値が変わらず、その後の 20px で 100(10段階 × 10)増えました。離した後の 20px は、また10だけ増えています。

小数の step は、桁数をそろえて丸める

不透明度のように step が 0.01 の欄では、0.8 + 段階 × 0.01 をそのまま値にすると、浮動小数点の誤差が出ます。Node.js で計算した結果です。

text
0.8 + -7 × 0.01 → そのまま 0.73 / roundToStep 0.73
0.8 + -37 × 0.01 → そのまま 0.43000000000000005 / roundToStep 0.43
0.8 + 3 × 0.01 → そのまま 0.8300000000000001 / roundToStep 0.83

0.43000000000000005 が入力欄に出ると読みにくいので、step の小数の桁数で丸める関数を通します。

ts
// step の小数の桁数(0.01 なら 2)
function decimals(step: number): number {
  const s = String(step);
  return s.includes('.') ? s.split('.')[1].length : 0;
}

// v を、min を基準にした step の倍数に丸め、min〜max の中に収める
export function roundToStep(v: number, min: number, max: number, step: number): number {
  const base = Number.isFinite(min) ? min : 0;
  const n = Math.round((v - base) / step) * step + base;
  const fixed = Number(n.toFixed(decimals(step)));
  return Math.min(max, Math.max(min, fixed));
}

toFixed() は、指定した桁数の文字列を返すので、Number で数に戻しています。

段階の基準を min にしているのは、MDN の input type="number" の step の説明にあるとおり、ブラウザーも min を基準に正しい値を決めるからです。この決まりに従うと、step が 5 で min が 3 なら、正しい値は 3、8、13…になります。roundToStep(57.3, 3, 300, 5) は 58 になり、min がないとき(-Infinity)は 0 を基準にして 55 になりました。

検証環境の Chromium で「不透明度」のラベルをドラッグしたときも、入力欄には丸めた値が出ました。初期値 0.5 から左へ 14px で 0.43、続けて左へ 60px で 0.13、右へ 200px で 1 です。

クリックだけなら入力欄へ、ドラッグの後は移さない

MDN の label のページにあるとおり、ラベルをクリックすると、関連付けた入力欄にフォーカスが移ります。数字を打ちたい人がラベルをクリックしたときは、この動きのままにしておきたいところです。

一方、ドラッグを終えてボタンを離したときにも、ラベルには click が届きます。検証環境では、何もしなければドラッグの後でも入力欄にフォーカスが移りました。

そこで、3px 以上動いたときを「ドラッグした」とみなして moved を true にし、その後の click で preventDefault() を呼んで、入力欄へのフォーカスを止めています。

操作 幅 フォーカスのある要素
ラベルをクリック 120(変わらない) 入力欄
2px だけ動かして離す 120(変わらない) 入力欄
10px 動かして離す 125 body(入力欄に移らない)
10px 動かして離す(preventDefault の行を消したもの) 125 入力欄

2px のような小さなぶれは、クリックとして扱われます。3px という境目は、この記事で決めた値です。

打っている途中の文字列は、別の state で持つ

入力欄に数字を打つときは、value をそのまま数として持つと困ることがあります。たとえば最小値が 20 の「幅」に「50」と打とうとすると、「5」を打った時点で範囲の外になります。そこで、打っている途中の文字列を draft という別の state に持ち、範囲の中の数になったときだけ親に渡しています。範囲の外の数は、フォーカスが外れたときか Enter を押したときに、roundToStep で範囲の中に収めます。

検証環境で「幅」の入力欄を操作した結果です。

操作 入力欄 幅
↑ を2回押す 122 122
空にして「5」と打った途中 5 122(変わらない)
続けて「0」と打つ 50 50
「999」と打って Enter 300 300
「7」と打って Tab 20 20
空にして Tab 20 20(元に戻る)

入力欄は type="number" なので、↑ を押すと step ずつ値が増えます。Playwright の getByRole('spinbutton', { name: '幅' }) で入力欄が見つかったことから、ラベルの文字がそのまま入力欄の名前になっていることも確かめました。MDN の input type="number" のアクセシビリティの節によると、この入力欄の暗黙のロールは spinbutton です。ドラッグはマウスで操作する人のための近道で、キーボードだけでも同じ値を入れられます。

draft が null のときは String(value) を渡し、入力欄の value が null にならないようにしています。React の input のリファレンスには、値を管理する入力欄に渡す value を undefined や null にしないように書かれています。

​Posted by 86