Reactで、ドラッグしてパネルの幅を変えられるようにする方法

​​React​CSS,Playwright,Pointer Events,TypeScript,アクセシビリティ

サイドバーの幅をドラッグで変えられるようにするには、サイドバーと本文のあいだに細い取っ手を置き、取っ手の onPointerDown で setPointerCapture() を呼びます。あとは onPointerMove で動いた距離を幅に足すだけで、マウスが取っ手から外れても幅が付いてきます。

ライブラリを使わずに、ResizablePanels という部品を作りました。タッチで途中で止まる、長い行で画面がはみ出す、キーボードで動かせない、といったつまずきどころも1つずつ確かめています。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0、Chromium 153 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、Playwright で Chromium を操作しました。マウスは Playwright の page.mouse、タッチは Chrome DevTools Protocol の Input.dispatchTouchEvent で再現しています。実機のスマートフォン、Firefox と Safari、サーバーでの描画(SSR)は試していません。

サイドバーと本文のあいだの取っ手を右へドラッグするとサイドバーが広がり、左へドラッグすると狭まって160pxで止まる
取っ手を押して右と左へドラッグしたときの動き。右下の幅の数字は、変わっているあいだだけピンクになる

幅を state に持ち、動いた距離を足す

ResizablePanels は、サイドバーの見出し(title)と中身(sidebar)、本文(children)を受け取ります。幅は width という state に持ち、aside の style に渡します。

tsx
import {
  useEffect,
  useRef,
  useState,
  type KeyboardEvent,
  type PointerEvent,
  type ReactNode,
} from 'react';

const MIN = 160;
const MAX = 480;
const STEP = 16;
const STORAGE_KEY = 'sidebar-width';

const clamp = (width: number) => Math.min(MAX, Math.max(MIN, width));

function readSavedWidth() {
  const saved = Number(localStorage.getItem(STORAGE_KEY));
  return saved > 0 ? clamp(saved) : 240;
}

type Props = {
  title: string;
  sidebar: ReactNode;
  children: ReactNode;
};

export function ResizablePanels({ title, sidebar, children }: Props) {
  const [width, setWidth] = useState(readSavedWidth);
  const [dragging, setDragging] = useState(false);
  // ドラッグを始めたときのマウスの位置と幅。描画には使わないので ref に持つ
  const start = useRef({ x: 0, width: 0 });

  // ドラッグ中は保存せず、手を離したあとに1回だけ保存する
  useEffect(() => {
    if (!dragging) localStorage.setItem(STORAGE_KEY, String(width));
  }, [width, dragging]);

  const handlePointerDown = (e: PointerEvent<HTMLDivElement>) => {
    if (e.button !== 0) return; // 左ボタン(タッチとペンも 0)だけで始める
    e.preventDefault(); // 文字の選択が始まらないようにする
    e.currentTarget.setPointerCapture(e.pointerId);
    start.current = { x: e.clientX, width };
    setDragging(true);
  };

  const handlePointerMove = (e: PointerEvent<HTMLDivElement>) => {
    if (!e.currentTarget.hasPointerCapture(e.pointerId)) return;
    setWidth(clamp(start.current.width + e.clientX - start.current.x));
  };

  // pointerup でも pointercancel でも、キャプチャが外れるとここに来る
  const handleLostPointerCapture = () => setDragging(false);

  const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
    const keys: Record<string, number> = {
      ArrowLeft: width - STEP,
      ArrowRight: width + STEP,
      Home: MIN,
      End: MAX,
    };
    if (!(e.key in keys)) return;
    e.preventDefault();
    setWidth(clamp(keys[e.key]));
  };

  return (
    <div className={dragging ? 'panels dragging' : 'panels'}>
      <aside id="sidebar" style={{ width }}>
        <h2 id="sidebar-title">{title}</h2>
        {sidebar}
      </aside>
      <div
        role="separator"
        aria-orientation="vertical"
        aria-labelledby="sidebar-title"
        aria-controls="sidebar"
        aria-valuenow={width}
        aria-valuemin={MIN}
        aria-valuemax={MAX}
        tabIndex={0}
        className="handle"
        onPointerDown={handlePointerDown}
        onPointerMove={handlePointerMove}
        onLostPointerCapture={handleLostPointerCapture}
        onKeyDown={handleKeyDown}
      />
      <main>{children}</main>
    </div>
  );
}

CSS は次のとおりです。.panels を横並びの flex にし、aside と取っ手は縮まないようにして、残りを main に回します。

css
.panels {
  display: flex;
  height: 100vh;
}
.panels aside {
  flex: none;
  overflow: auto;
}
.handle {
  flex: none;
  width: 8px;
  cursor: col-resize;
  touch-action: none; /* タッチで動かしたときに、ブラウザのスクロールに取られないようにする */
  background: #d7eef3;
}
.handle:hover,
.handle:focus-visible,
.panels.dragging .handle {
  background: #00c8e8;
}
.panels main {
  flex: 1;
  overflow: auto; /* 長い行があっても main が縮み、はみ出た分は main の中でスクロールする */
}
/* ドラッグ中は、マウスが枠から外れても形を保ち、文字を選ばせない */
.panels.dragging {
  cursor: col-resize;
  user-select: none;
}

使う側は、次のように書きます。

tsx
<ResizablePanels title="目次" sidebar={<ul>{/* 目次の項目 */}</ul>}>
  <h1>本文</h1>
  {/* 本文 */}
</ResizablePanels>

新しい幅は、押したときの幅に「押したときから動いた距離」を足して求めます。押したときの値は描画に使わないので、useRef に持たせています。求めた幅は clamp で160〜480pxに収めます。検証で取っ手を左へ400px、右へ800pxドラッグすると、幅はそれぞれ160と480で止まりました。

setPointerCapture で、取っ手から外れても追いかける

取っ手の幅は8pxしかありません。マウスを少し速く動かすと、次の pointermove が届くころには、マウスはもう取っ手の上にいません。ふつうはイベントがマウスの下にある要素に届くので、取っ手の onPointerMove は呼ばれなくなります。

setPointerCapture() の説明にあるとおり、これを呼ぶと、そのポインターのイベントは、キャプチャを外すか pointerup が起きるまで、呼んだ要素に届きます。React でも、onPointerDown や onLostPointerCapture を div に渡せます。

setPointerCapture() を呼ばない版と比べました。10pxずつ動かしても、100pxを1回で動かしても、幅は変わりませんでした。取っ手から100px右にある要素は main で、マウスがそこへ移るとイベントは取っ手に届きません。1pxずつ動かしたときだけ、取っ手が付いてきました。

text
--- 2. setPointerCapture を呼ばない版 ---
最初の幅: 240
右へ100px(10回に分けて動かす): 240
右へ100px(1回で動かす): 240
右へ100px(1pxずつ100回に分けて動かす): 340
1回で動かした先にある要素: MAIN

ResizablePanels では、100pxを一度に動かしても、300px動かしても、幅が付いてきました(300pxのときは上限の480で止まっています)。

text
--- 1. ResizablePanels で、ゆっくりと速くドラッグする ---
最初の幅: 240 aria-valuenow: 240
右へ100px(10回に分けて動かす): 340
左へ100px(1回で動かす): 240
右へ300px(1回で動かす): 480 aria-valuenow: 480

handlePointerMove では、hasPointerCapture() でキャプチャ中かどうかを確かめています。コードの上では、押していないときにマウスが取っ手の上を通っても、キャプチャがないので幅は変わりません。pointermove では dragging の state を見なくても、キャプチャの有無だけでドラッグ中かを判定できます。

ドラッグの終わりは lostpointercapture イベントで受けています。Pointer Events の解説によれば、キャプチャは pointerup か pointercancel のあとに自動で外れます。どちらで終わっても onLostPointerCapture が呼ばれるので、終わりの処理を1か所にまとめられます。

ドラッグ中に文字が選ばれないようにする

取っ手から本文の上までドラッグすると、ブラウザが本文の文字を選んでしまうことがあります。pointerdown で選択の対策を何も入れない書き方では、実際に68文字が選ばれました。この版では、キャプチャを使わず、preventDefault() も user-select: none もない状態にしています。

text
--- 4. ドラッグ中に文字が選ばれるか(取っ手から本文の上までドラッグ) ---
キャプチャなし・preventDefault なし・user-select なし: 選ばれた文字数=68
キャプチャなし・preventDefault だけ: 選ばれた文字数=0
キャプチャなし・user-select: none だけ: 選ばれた文字数=0
setPointerCapture だけ: 選ばれた文字数=0
ResizablePanels(3つともあり): 選ばれた文字数=0

Chromium 153 では、pointerdown での preventDefault()、ドラッグ中の user-select: none、setPointerCapture() の、どれか1つがあれば文字は選ばれませんでした。ほかのブラウザでは試していないので、ResizablePanels には3つとも入れています。user-select: none を付ける dragging のクラスは、ドラッグ中にマウスが取っ手から外れても col-resize のカーソルを保つ役目も兼ねています。

タッチでは touch-action: none がないと途中で止まる

指で取っ手を横に動かすと、ブラウザはそれを画面のスクロールと受け取ることがあります。touch-action の説明によれば、ブラウザがスクロールやズームを始めると、ページには pointercancel が届きます。touch-action: none を指定した要素では、ブラウザはスクロールやズームをしません。

タッチの操作を Chrome DevTools Protocol で再現し、指を右へ100px動かしました。touch-action を指定しない版では、pointermove が2回届いたところで pointercancel になり、幅は20pxしか広がりませんでした。

text
--- 8. タッチで動かす(CDP の Input.dispatchTouchEvent で再現) ---
touch-action を指定しない版: touch-action=auto 指を右へ100px動かしたあとの幅=260
  取っ手に届いたイベント: pointerdown×1 → pointermove×2 → pointercancel×1 → lostpointercapture×1
ResizablePanels: touch-action=none 指を右へ100px動かしたあとの幅=340
  取っ手に届いたイベント: pointerdown×1 → pointermove×10 → pointerup×1 → lostpointercapture×1

比べた版も、pointerdown で preventDefault() を呼んでいます。それでも pointercancel になりました。touch-action の説明にも、Touch events の preventDefault() でブラウザの操作を止める場合でも、イベントの処理が呼ばれる前にブラウザが意図を知れるよう、touch-action も使うべきだと書かれています。スクロールさせたくない取っ手には、CSS で touch-action: none を指定します。

長い行があるときは main の overflow で縮める

本文に、折り返さない長い行(コードなど)があるときは注意が必要です。flex の子は、ふだんは中身の最小の幅より狭くなりません。min-width の説明によれば、flex の子の min-width: auto は中身の最小の幅になりますが、その子がスクロールする要素(overflow が auto・scroll・hidden のどれか)なら0になります。

画面の幅1000pxで、x を120個並べた折り返さない1行(全体で140文字)がある本文を置き、サイドバーを480pxに広げて比べました。

text
--- 7. main の overflow を変えた版(長い行がある) ---
overflow: visible: aside=480 main=1144 ページの横幅=1632 画面の幅=1000
overflow: visible と min-width: 0: aside=480 main=512 ページの横幅=1608 画面の幅=1000
ResizablePanels(overflow: auto): aside=480 main=512 ページの横幅=1000 画面の幅=1000

overflow: visible のままでは main が1144pxから縮まず、ページ全体が横にはみ出しました。min-width: 0 を足すと main は512pxに縮みますが、長い行は main の外へはみ出したままです。overflow: auto にすると main が縮み、はみ出た分は main の中で横にスクロールするようになって、ページの幅は画面に収まりました。

キーボードでも幅を変えられるようにする

マウスを使わない人も幅を変えられるように、取っ手に tabIndex={0} を付けてフォーカスできるようにし、矢印キーで動かします。役割や属性は、WAI-ARIA Authoring Practices Guide のウィンドウスプリッターのパターンに合わせました。

  • 取っ手の role は separator にする
  • aria-valuenow に今の位置、aria-valuemin と aria-valuemax に動かせる範囲を入れる
  • 見えている見出しがあれば、aria-labelledby で名前にする
  • 大きさが変わる側のパネルを aria-controls で指す
  • 左右の矢印キーで動かし、Home で最小、End で最大にする(Home と End は任意)

パターンでは、aria-valuenow などは0〜100で表すことが多いとされています。ResizablePanels では、幅のピクセル数をそのまま入れました。

Tab で取っ手にフォーカスし、キーを順に押した結果です。幅と aria-valuenow が同じように変わり、範囲の外へは出ませんでした。Playwright で取り出したアクセシビリティツリーでは、取っ手が「目次」という名前の separator になっています。

text
--- 5. キーボードで動かす ---
Tab でフォーカスした要素: separator
ArrowRight: 幅=256 aria-valuenow=256
ArrowRight: 幅=272 aria-valuenow=272
ArrowLeft: 幅=256 aria-valuenow=256
Home: 幅=160 aria-valuenow=160
ArrowLeft: 幅=160 aria-valuenow=160
End: 幅=480 aria-valuenow=480
ArrowRight: 幅=480 aria-valuenow=480
アクセシビリティツリー: - separator "目次"

パターンには、Enter でパネルをたたみ、もう一度押すと元の幅に戻す操作もあります。Home や End と違って任意とはされていませんが、ResizablePanels には入れていません。

手を離したときだけ幅を保存する

次にページを開いたときも同じ幅で出るように、幅を localStorage に保存しています。useEffect の中で、dragging が false のときだけ書き込むので、ドラッグ中の pointermove のたびには書きません。キーボードで動かすときは dragging が false のままなので、コードの上では1回押すごとに保存されます。

読み込むほうは、useState(readSavedWidth) のように関数そのものを渡しています。useState の説明にあるとおり、関数を渡すと最初の描画のときだけ呼ばれ、再描画のたびに localStorage を読み直しません。localStorage の値は文字列なので、Number() で数に戻し、clamp で範囲に収めてから使います。

setItem の呼び出しを数えながら12回に分けてドラッグすると、保存は手を離したあとの1回だけでした。読み込み直したあとも、幅は360pxのままでした。

text
--- 6. 幅を保存して、読み込み直しても残る ---
ドラッグ1回(12回動かす)で保存した回数: 1 ["sidebar-width=360"]
読み込み直したあとの幅: 360

​Posted by 86