【ReactJS】明日から使いたいライブラリ8選!
2018-11-02

ReactJS の案件に携わってから1年近く経ったので 今までに出会ったライブラリの中で便利だったものを自作の DEMO とコード付きで紹介していきます。

CSSは頑張りません。ださくてもしょうがない。

備考

  • バージョンは 執筆時点の最新を使います。
  • バージョンによってオプションとかは変わったりするので、あくまで参考程度に御覧ください。
    • 詳しいオプションは公式ドキュメントを参照してください。
  • 間違っていたり、もっといいやり方がある場合は優しめに教えてください。
  • いろいろ動作検証しながらやったので不要なコードが多少残っているかもしれませんが気にしないでください。
目次

Settings

ビルドに使用した設定ファイルは以下です。

package.json

package.json
{
  "name": "cronote-react-libraries",
  "version": "1.0.0",
  "description": "",
  "main": "webpack.config.js",
  "dependencies": {
    "@babel/polyfill": "^7.0.0",
    "@babel/runtime": "^7.1.2",
    "react": "^16.6.0",
    "react-autocomplete": "^1.8.1",
    "react-dnd": "^5.0.0",
    "react-dnd-html5-backend": "^5.0.1",
    "react-dom": "^16.5.2",
    "react-draggable": "^3.0.5",
    "react-dropzone": "^5.1.0",
    "react-modal": "^3.5.1",
    "react-notification-system": "^0.2.17",
    "react-pdf": "^3.0.5",
    "react-rnd": "^9.0.2",
    "react-select": "^2.0.0"
  },
  "devDependencies": {
    "@babel/cli": "^7.1.2",
    "@babel/core": "^7.1.2",
    "@babel/plugin-proposal-class-properties": "^7.1.0",
    "@babel/plugin-proposal-decorators": "^7.1.2",
    "@babel/plugin-proposal-object-rest-spread": "^7.0.0",
    "@babel/plugin-transform-runtime": "^7.1.0",
    "@babel/preset-env": "^7.1.0",
    "@babel/preset-react": "7.0.0",
    "@babel/preset-stage-0": "^7.0.0",
    "babel-loader": "^8.0.4",
    "stylus": "^0.54.5",
    "stylus-loader": "^3.0.2",
    "webpack": "^4.23.1",
    "webpack-cli": "^3.1.0"
  },
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "build": "webpack"
  },
  "author": "",
  "license": "ISC"
}
webpack.config.js

webpack.config.js
const path = require("path");

module.exports = {
  context: __dirname,
  // mode: "development",
  mode: "production",
  devtool : 'source-map',
  entry: [
    path.resolve("./entry.js")
  ],
  output: {
    path: path.resolve('.'),
    filename: "bundle.js",
    publicPath: './',
  },
  module: {
    rules: [
      {
        test: /\.css$/,
        use: ["style-loader", "css-loader"],
      },
      {
        test: /\.styl$/,
        use: ["style-loader", "css-loader", "stylus-loader"],
      },
      {
        test: /\.js?$/, 
        exclude: /node_modules/,
        use: [
          {
            loader: 'babel-loader',
          }
        ]
      }
    ]
  },
  resolve: {
    extensions: [".js", ".json", ".styl"],
    modules: ['./node_modules'],
    alias: {
      '@top': path.join(__dirname, '.'),
      '@components': path.join(__dirname, './components'),
      '@styles': path.join(__dirname, './styles'),
    },
  },
  plugins: [],
  watchOptions: {
    poll: 1000,
  },
};
entry.js

entry.js
import '@babel/polyfill'

import React from 'react'
import ReactDOM from 'react-dom'

import NotificationComponent from '@components/react-notification-system'
import PdfComponent from '@components/react-pdf'
import ModalComponent from '@components/react-modal'
import DraggableComponent from '@components/react-draggable'
import RndComponent from '@components/react-rnd.js'
import {default as SelectComponent, AsyncComponent as AsyncSelectComponent} from '@components/react-select'
import DropzoneComponent from '@components/react-dropzone'
import AutoCompleteComponent from '@components/react-autocomplete'
import DnDComponent from '@components/react-dnd'

import '@styles/common.styl'

ReactDOM.render(<NotificationComponent />, document.querySelector('#demo-react-notification-system'))
ReactDOM.render(<PdfComponent />, document.querySelector('#demo-react-pdf'))
ReactDOM.render(<ModalComponent />, document.querySelector('#demo-react-modal'))
ReactDOM.render(<DraggableComponent />, document.querySelector('#demo-react-draggable'))
ReactDOM.render(<RndComponent />, document.querySelector('#demo-react-rnd'))
ReactDOM.render(<SelectComponent />, document.querySelector('#demo-react-select'))
ReactDOM.render(<AsyncSelectComponent />, document.querySelector('#demo-react-async-select'))
ReactDOM.render(<DropzoneComponent />, document.querySelector('#demo-react-dropzone'))
ReactDOM.render(<AutoCompleteComponent />, document.querySelector('#demo-react-autocomplete'))
ReactDOM.render(<DnDComponent />, document.querySelector('#demo-react-dnd'))
.babelrc

.babelrc
{
  "presets": [
    ["@babel/preset-env", {
      "targets": {
        "browsers": ["last 2 Chrome versions"]
      }
    }], 
    "@babel/preset-react"
  ],
  "plugins": [
    "@babel/plugin-proposal-object-rest-spread",
    "@babel/plugin-proposal-class-properties",
    [
      "@babel/plugin-proposal-decorators",
      {
        "decoratorsBeforeExport": true
      }
    ]
  ]
}
styles/common.styl

styles/common.styl
.component
  background-color #ffffff
  padding 10px

react-notification-system

通知用のライブラリです。

バージョン
0.2.17
リポジトリ
https://github.com/igorprado/react-notification-system
デモ

通知位置とメッセージを入力してボタンを押してください。

通知内にボタンを設定できるんですが何も思い浮かばなかったので、Twitter のリンクにしました。フォローしてね!

コード
components/react-notification-system.js
import React from 'react'
import NotificationSystem from 'react-notification-system'



export default class Component extends React.Component {
  constructor (props) {
    super(props)
    this.state = {
      level: 'success', 
      position: 'tr', 
      uid: 0,
      autoDismiss: 5,
    }
  }
  addNotification (notification) {
    this.refs.notificationSystem.addNotification({
      ... notification,
      action: {
        label: 'Follow me',
        callback: () => window.open('https://twitter.com/crohaco'),
      },
    })
    this.setState({uid: this.state.uid + 1})
  }
  render () {
    return <div className="component">
      <fieldset>

        <div>Title:
          <input value={this.state.title || ''} onChange={e => this.setState({title: e.target.value})} />
        </div>

        <div>Message:
          <input value={this.state.message || ''} onChange={e => this.setState({message: e.target.value})} />
        </div>

        <div>Level:
          &nbsp;<label>成功:<input type="radio" checked={this.state.level === 'success'} onChange={e => this.setState({level: 'success'})} /></label>
          &nbsp;<label>エラー:<input type="radio" checked={this.state.level === 'error'} onChange={e => this.setState({level: 'error'})} /></label>
          &nbsp;<label>警告:<input type="radio" checked={this.state.level === 'warning'} onChange={e => this.setState({level: 'warning'})} /></label>
          &nbsp;<label>情報:<input type="radio" checked={this.state.level === 'info'} onChange={e => this.setState({level: 'info'})} /></label>
        </div>

        <div>Position:
          &nbsp;<label>左上:<input type="radio" checked={this.state.position === 'tl'} onChange={e => this.setState({position: 'tl'})} /></label>
          &nbsp;<label>中上:<input type="radio" checked={this.state.position === 'tc'} onChange={e => this.setState({position: 'tc'})} /></label>
          &nbsp;<label>右上:<input type="radio" checked={this.state.position === 'tr'} onChange={e => this.setState({position: 'tr'})} /></label>
          &nbsp;<label>左下:<input type="radio" checked={this.state.position === 'bl'} onChange={e => this.setState({position: 'bl'})} /></label>
          &nbsp;<label>中上:<input type="radio" checked={this.state.position === 'bc'} onChange={e => this.setState({position: 'bc'})} /></label>
          &nbsp;<label>右下:<input type="radio" checked={this.state.position === 'br'} onChange={e => this.setState({position: 'br'})} /></label>
        </div>

        <div>Dismiss:
          <input 
            placeholder="何秒で消す?0で無限"
            type="number" value={this.state.autoDismiss} 
            onChange={e => this.setState({autoDismiss: parseInt(e.target.value)})} 
          /> second(s) later, the notification will disappear.
        </div>

        <button onClick={() => this.addNotification(this.state)}>Add notification</button>
      </fieldset>
      <NotificationSystem ref="notificationSystem" />
    </div>
  }
}

react-pdf

PDF.js という PDF を描画できるライブラリがあり、これはReact用のラッパーです。

PDF.js を生で操作すると (たしか) pdfjs-dist/build/pdfpdfjs-dist/web/pdf_viewer の読み込みを別途行わないといけないんですが、 これはコンポーネント化されてるので細かい点を気にせず使えます。

バージョン
3.0.5
リポジトリ

https://github.com/wojtekmaj/react-pdf

警告

https://github.com/diegomura/react-pdf ではないです

Star はこっちのほうが多いんですが、ドキュメントを読む限りでは 既存のPDFを描画する機能はなく、 そもそも用途が PDF を作るためのライブラリのようです。

見逃してる可能性もあるので、「できるよー」って場合は教えてください。

デモ

PDFファイルを添付するとHTML上に描画します。複数ページある場合はページングできます。 大きすぎると固まるので小さめのファイルでお試しください。

勝手にアップロードされたりはしないので安心してください。(そもそも受け取るサーバを持ってないので..

コード
components/react-pdf.js
import React from 'react'
import { Document, Page } from 'react-pdf';

import '@styles/react-pdf.styl'

export default class Component extends React.Component {
  constructor (props) {
    super(props)
    this.state = {page: 1, base64: null, name: null}
  }

  handleChange (e) {
    const file = e.target.files[0]
    const reader = new FileReader()
    reader.readAsDataURL(file)
    reader.onload = () => {
      this.setState({
        base64: reader.result,
        name: file.name,
      })
    }
  }

  handleDocumentLoad ({ numPages }) {
    this.setState({numPages})
  }

  handleButtonClick (page) {
    this.setState({page})
  }

  render () {
    
    return <div className="component">
      PDFを添付してください:
      <input type="file" onChange={this.handleChange.bind(this) } />
      <Document 
        file={this.state.base64} style={{border: 'dotted 1px #aaa'}}
        onLoadSuccess={this.handleDocumentLoad.bind(this)}
      >
        <Page 
          pageNumber={this.state.page}
          style={{border: 'solid 2px #000', height: 300}}
        />
      </Document>
      <div>{this.state.name}</div>
      <button
        disabled={this.state.page <= 0}
        onClick={() => this.handleButtonClick(this.state.page - 1)}
      >Prev</button>
      {this.state.page || 1} / {this.state.numPages || '-'}
      <button
        disabled={this.state.page >= this.state.numPages || !this.state.numPages}
        onClick={() => this.handleButtonClick(this.state.page + 1)}
      >Next</button>
      
    </div>
  }
}
styles/react-pdf.styl
canvas
  width auto !important
  height 100% !important

.react-pdf__Page
  height 400px
参考

react-modal

バージョン
3.5.1
リポジトリ
https://github.com/reactjs/react-modal
デモ

YouTube の動画をモーダル表示するようにしてみました

コード
components/react-modal.js
import React from 'react'
import Modal from 'react-modal'

import '@styles/react-modal.styl'

export default class Component extends React.Component {
  constructor (props) {
    super(props)
    this.state = {open: false}
  }
  handleOpen (options) {
    this.setState({
      ... options,
      open: true,
    })
  }
  handleClose () {
    this.setState({open: false})
  }
  render () {
    return <div className="component">
      <table>
        <tbody>
          <tr>
            <th>
              <button
                onClick={() => this.handleOpen({
                  code: 'W4TtTzSxqv8',
                  shouldCloseOnOverlayClick: true,
                  shouldCloseOnEsc: false,
                  closeButton: null,
                })}
              >1</button>
            </th>
            <td>モーダルの外側を押すと閉じます</td>
          </tr>
          <tr>
            <th>
              <button
                onClick={() => this.handleOpen({
                  code: 'XWo627F7CeU',
                  shouldCloseOnOverlayClick: false,
                  shouldCloseOnEsc: true,
                  closeButton: null,
                })}
              >2</button>
            </th>
            <td>Escape キーを押すと閉じます</td>
          </tr>
          <tr>
            <th>
              <button
                onClick={() => this.handleOpen({
                  code: '9qRCARM_LfE',
                  shouldCloseOnOverlayClick: false,
                  shouldCloseOnEsc: false,
                  closeButton: <button 
                    onClick={this.handleClose.bind(this)}
                    style={{backgroundColor: '#f00', position: 'absolute', top: 10, right: 10}} 
                  >x</button>
                })}
              >3</button>
            </th>
            <td>動画右上の `x` を押すと閉じます</td>
          </tr>
        </tbody>
      </table>

      <Modal
        isOpen={this.state.open}
        onAfterOpen={this.afterOpenModal}
        onRequestClose={this.handleClose.bind(this)}
        style={{backgroundColor: '#005'}}
        contentLabel="ようつべ"

        // close
        shouldCloseOnOverlayClick={this.state.shouldCloseOnOverlayClick}
        shouldCloseOnEsc={this.state.shouldCloseOnEsc}
      > 
        {this.state.closeButton}
        <iframe 
          width={560} 
          height={315} 
          src={`https://www.youtube.com/embed/${this.state.code}`}
          frameBorder="0" 
          allow="autoplay; encrypted-media" 
          allowFullScreen
        />
      </Modal>
    </div>
  }
}
styles/react-modal.styl
#demo-react-modal
  th
    width 50px

.ReactModal__Overlay
  z-index 3
  background-color rgba(0, 0, 50, 0.7) !important

.ReactModal__Content
  padding 0 !important
  top 50% !important
  left 50% !important
  right auto !important 
  bottom auto !important
  margin-left -280px
  margin-top -158px

react-select

セレクトボックスを高機能にしたライブラリです。

jQuery だと select2 ってライブラリがありましたが、それの ReactJS 版と考えてよいです。 ラッパーとかじゃないので当然互換性はないです。

通常の Select と 非同期通信用のSelect (AsyncSelect) があるので両方共サンプルを用意しました。

バージョン
2.0.0
リポジトリ
https://github.com/JedWatson/react-select
デモ
Select 名前を当ててみよう
AsyncSelect GitHubのリポジトリを検索してみよう
コード
components/react-select.js
import React from 'react'
import Select from 'react-select';
import AsyncSelect from 'react-select/lib/Async'

import '@styles/react-select.styl'

const answers = [
  {value: 1, label: 'crochaco'}, 
  {value: 2, label: 'chrohaco'},
  {value: 3, label: 'chrohako'},
  {value: 4, label: 'kurohako'},
  {value: 5, label: 'crohako'},
  {value: 6, label: 'chrohacho'},
  {value: 7, label: 'kurohaco'},
  {value: 8, label: 'crohaco'},
  {value: 9, label: 'chorohaco'},
]


export default class Component extends React.Component {
  constructor (props) {
    super(props)
    this.state = {answer: null}
  }

  render () {
    return <div className="component">
      <Select 
        className='selectbox'
        value={this.state.answer}
        onChange={answer => this.setState({answer})}
        options={answers}
        placeholder='正しいのはどれでしょう'
      />
      <div className="name-result">
      {this.state.answer ?
        this.state.answer.value === 8 ? 'あたり!' : 'はずれ'
      : ''}
      </div>
    </div>
  }
}

export class AsyncComponent extends React.Component {
  constructor (props) {
    super(props)
    this.state = {selected: null}
  }

  async fetchRepositories (q) {
    const res = await fetch(`https://api.github.com/search/repositories?q=${q}`)
    const data = await res.json()
    const result = data.items.map(o => ({
      value: o.id,
      label: (<a
        style={{height: 30}}
        href={o.html_url}
        target="_blank"
      >
        <img 
          src={o.owner.avatar_url}
          style={{
            width: '30px',
            verticalAlign: 'middle',
            marginRight: '5px',
          }}
        />
        {o.name}
      </a>),
    }))
    return result
  }

  render () {

    return <div className="component">
      <AsyncSelect
        isClearable
        loadOptions={this.fetchRepositories.bind(this)}
        backspaceRemoves={true}
        onChange={selected => this.setState({selected})}
        value={this.state.selected}
        noOptionsMessage={({inputValue}) => inputValue ? 'No options' : 'Type to search'}
        placeholder="検索キーワード"
      />
    </div>
  }
}
styles/react-select.styl
.name-result
  padding 5px
  color #070
  &:before
    content '結果:'

複数選択 の場合は isMulti を props に指定します。

今回は指定してませんが、 className でクラスを指定したほうがいいです。 クラスを指定しないと css-xxxxxxx (xxxxxxxは可変) のようなクラス名しかつかないので、内側のDOMにスタイルを当てられません。 (メニューのz-indexが負けて後ろに隠れちゃうのはよくあります)

備考

少し前に version 2.0.0 がリリースされました。 v2 では使い方が大きく変わり、 v1 のコードでは動かなくなってます。

具体的な変更点は以下です。

  • 選択中は key ではなく、 {key, label} のオブジェクトを指定するようになった
  • AsyncSelect は react-select ではなく react-select/lib/Async から(default)インポートするようになった
  • loadOptions の関数内で自分で {key, label} の配列に整形して返却する(必要がある)ようになった。
    • v1 の AsyncSelect では filterOptionvalueKey, labelKey などでレスポンスの整形を ライブラリに任せていた
  • AsyncSelect では 入力文字列がないときにリクエストが発生しなくなった

こんな感じです。いろいろと統一されてわかりやすくなってると思います。

react-dropzone

ドラッグアンドドロップでファイルを添付(アップロード)するのに便利なライブラリです。

DropzoneJS というライブラリがあり、これはReact用のラッパーです。

バージョン
5.1.0
リポジトリ
https://github.com/react-dropzone/react-dropzone
デモ
コード
components/react-dropzone.js
import React from 'react'
import Dropzone from 'react-dropzone'

const myImage = 'https://pbs.twimg.com/profile_images/959293912690049025/xH9RPWDc_400x400.jpg'

export default class Component extends React.Component {
  constructor (props) {
    super(props)
    this.state = {files: []}
  }

  onDrop (files) {
    this.setState({files: [].concat(this.state.files).concat(files)})
  }

  render () {
    return <div className="component">
      <Dropzone onDrop={this.onDrop.bind(this)} size={150} style={{width: '100%'}}>
        <ul className="preview">
          {
            this.state.files.map((file, index) => (
              <li 
                key={index} 
                style={{
                  display: 'inline-block',
                  listStyleType: 'none',
                  width: '200px',
                  objectFit: 'fill',
                  marginRight: '10px',
                  position: 'relative',
                }}
              >
                <img src={file.preview || myImage} />
                <div className="name"><i>{index + 1}.</i> {file.name}</div>
                <div
                  style={{
                    position: 'absolute',
                    backgroundColor: '#fff',
                    border: 'solid 1px #000',
                    top: 0,
                    right: 0,
                    padding: '5px',
                    opacity: 0.5,
                    cursor: 'pointer',
                  }}
                  onClick={e => {
                    e.stopPropagation()
                    this.setState({files: this.state.files.filter((v, i) => i !== index)})
                  }}
                >Cancel</div>
              </li>
            ))
          }
        </ul>
        <p>Drag an image to upload</p>
      </Dropzone>
  	</div>
  }
}

多少余談も入りますが、Vue では たしか vue2-dropzone というのがあり、 (たしか)そちらはドラッグした瞬間にファイルがアップロードされます。

Reactのほうは自分でアップロード処理も自分でやる必要があります。 その場合、ドロップされたファイル配列中の要素が File オブジェクトなので そのままアップロードしてあげればよいです。

備考

JSON 形式で送信したい場合は Base64 化など工夫が必要です

react-autocomplete

文字通り 入力補完のライブラリです。 入力文字に対応した選択肢が表示され、選択した選択肢の内容で補完されます。

バージョン
1.8.1
リポジトリ
https://github.com/reactjs/react-autocomplete
デモ

名前の入力を補完してみましょう (画像の周りに変なスタイルがあたってるけど気にしないで)

コード
components/react-autocomplete.js
import React from 'react'
import AutoComplete from "react-autocomplete"

const members = [
  {name: 'crohaco', twitter: 'https://twitter.com/crohaco', icon: 'https://pbs.twimg.com/profile_images/959293912690049025/xH9RPWDc_400x400.jpg'},
  {name: 'shimizukawa', twitter: 'https://twitter.com/shimizukawa', icon: 'https://pbs.twimg.com/profile_images/1452813575/shimizukawa_half1_megane_180_400x400.png'},
  {name: 'takanory', twitter: 'https://twitter.com/takanory', icon: 'https://pbs.twimg.com/profile_images/192722095/kurokuri_400x400.jpg'},
  {name: 'tell-k', twitter: 'https://twitter.com/tell_k', icon: 'https://pbs.twimg.com/profile_images/1045138776224231425/3GD8eWeG_400x400.jpg'},
]

export default class Component extends React.Component {
  constructor (props) {
    super(props)
    this.state = {name: ''}
  }

  render () {
    return <div className="component">
      <AutoComplete 
        // 配列の中の要素から補完用の文字列を抽出する処理を書く
        getItemValue={(item) => item.name}
        items={members.filter(member => member.name.includes(this.state.name))}
        renderItem={(item, isHighlighted) =>
          <div style={{verticalAlign: 'middle', background: isHighlighted ? 'lightgray' : 'white' }}>
            <a 
              target="_blank" 
              href={item.twitter}
              style={{border: 'none', backgroundColor: 'none', padding: 0, dipslay: 'inline-block'}}
            >
              <img src={item.icon} style={{width: 30, height: 30}}/>
            </a>
            <div style={{display: 'inline-block', minWidth: 200}}>{item.name}</div> 
          </div>
        }
        wrapperStyle={{
          position: 'relative',
          border: 'solid 1px #800',
        }}
        menuStyle={{
          border: 'solid 2px #080',
          backgroundColor: '#dfd',
          zIndex: 2,
          position: 'absolute',
          top: 30,
          left: 0,
          overflow: 'auto',
          maxHeight: 100,
        }}
        value={this.state.name || ''}
        inputProps={{
          placeholder: "input name",
          style: {fontSize: 14, width: '100%', padding: 3},
        }}
        onChange={e => this.setState({name: e.target.value})}
        onSelect={name => this.setState({name})}
      />
    </div>
  }
}

react-rnd

DOM の リサイズと 移動(ドラッグアンドドロップ) を行うためのライブラリです。

単体で リサイズや ドラッグアンドドロップを行うライブラリは他にもありましたが 組み合わせるといろいろ不具合があって困ってました。

そんな中で奇跡的に見つけました。細かい制御もできて便利です。

バージョン
8.0.2
リポジトリ
https://github.com/bokuweb/react-rnd
デモ
コード
components/react-rnd.js
import React from 'react'
import { Rnd } from 'react-rnd'

import '@styles/react-rnd.styl'

const text = `
これは生涯ちっともその専攻家というもののところに受けなけれあり。
よし結果に煩悶式も何でもかでもこの任命たたでもをめがけとくれないをは邁進廻らたたから、わざわざとは悟っでしょですませます。先輩をした事もさきほど前にどうかんないなけれ。勢いネルソンさんに焦燥国民更に尊敬であるん気その敵私か安心をというご煩悶ですたなけれなかっと、その昔も私か道根性がするから、大森さんののに徳義の私をとにかくお指導と威張ってそれ欄がお話が用いようにできるだけ肝立証をしたくなて、よほどもし意味から向いますのでいるましはずを誘き寄せるですでし。またそうして今模範に焼いのも必ず立派としませて、いわゆる秋刀魚をはなっありばという心に思っといますまし。
そのため場所の時その主義は私上と限らないかと岡田さんをなっなな、大学の一部ますという不創設ますですたば、理窟の中を他に将来でものこの世を今日離さているで、突然の今日をいうてその時をはなはだ云っなましと信じなのたが、ないななとそうご傍点あるです方うなけれた。
`


export default class Component extends React.Component {
  constructor (props) {
    super(props)
    this.state = {
      width: '60%',
      height: 200,
    }
  }

  handleResizeStart (e, dir, element) {
    console.log('リサイズ開始', e, dir, element)
  }
  handleResize (e, dir, ref, delta, position) {
    this.setState({
      width: ref.offsetWidth,
      height: ref.offsetHeight,
      ...position,
    })
  }
  handleResizeStop (e, dir, element) {
    console.log('リサイズ終了', e, dir, element)
  }
  handleDragStart (e, data) {
    console.log('移動開始', e, data)
  }
  handleDrag (e, data) {
    console.log('移動中', e, data)
  }
  handleDragStop (e, data) {
    console.log('移動終了', e, data)
    
  }
  handleOpen (e) {
    this.setState({display: true})
    this.refs.rnd.updatePosition({x: 10, y: 10})
  }
  handleClose (e) {
    this.setState({display: false})
  }

  render () {
    return <div className="component">
      <button
        onClick={this.handleOpen.bind(this)}
      >Display window</button>
      <Rnd
        ref="rnd"
        className="movable-window"
        style={{
          position: 'fixed',
          display: this.state.display ? 'flex' : 'none',
          border: 'solid 2px #555',
          zIndex: 2,
        }}
        size={{
          width: this.state.width, 
          height: this.state.height
        }}
        dragHandleClassName='side-menu'
        resizeHandleWrapperClass='resize-handler-wrapper'
        // handlers
        onResizeStart={this.handleResizeStart.bind(this)}
        onResize={this.handleResize.bind(this)}
        onResizeStop={this.handleResizeStop.bind(this)}
        onDragStart={this.handleDragStart.bind(this)}
        onDrag={this.handleDrag.bind(this)}
        onDragStop={this.handleDragStop.bind(this)}
      >
        <div 
          className="side-menu"
          style={{
            backgroundColor: '#555',
            width: "50px",
            cursor: 'move',
          }}
        >
          <i
            style={{
              display: 'block',
              width: '45px',
              height: '45px',
              border: 'solid 1px #fff',
              borderRadius: '50%',
              backgroundColor: '#ddd',
              textAlign: 'center',
              fontStyle: 'normal',
              fontSize: '30px',
              lineHeight: '50px',
              cursor: 'pointer',
            }}
            onClick={this.handleClose.bind(this)}
          >&#x274c;</i>
        </div>
        <div style={{
          flex: 1,
          backgroundColor: '#eee',
          overflow: 'auto',
        }}>
          {text}
        </div>
      </Rnd>
    </div>
  }
}

デフォルトでは position が absolute なんですが、これだとスクロールについて来れなくて不便なので fixed に直しています。

ただ、見えないところに吹っ飛んでしまうので updatePosition メソッドで位置を初期化しています。 そのためだけに ref props を指定しています。

備考

  • ただのバグかもしれませんが position props は 指定すると リサイズしたときに位置が固定されて、 左側を縮小したのに右側から縮むという直感的でない動作をするので管理していません。
  • 同じ作者が出している リサイズ機能だけと思われる re-resizable というライブラリがありますが、このライブラリと比べてどのような優位性があるのかは不明です(調べてません)。
  • drag and drop だけであれば以下のライブラリも使いやすかったです。(作者は違います)
react-draggable
バージョン
3.0.5
リポジトリ
https://github.com/mzabriskie/react-draggable
デモ

画像の真ん中あたりにドラッグできるポイントがあります。移動できる範囲は地味に制限してあります。

コード
components/react-draggable.js
import React from 'react'
import Draggable from 'react-draggable'
import '@styles/react-draggable.styl'

const tellK = 'https://pbs.twimg.com/profile_images/1045138776224231425/3GD8eWeG.jpg'

export default class Component extends React.Component {
  constructor (props) {
    super(props)
    this.state = {movable: true}
  }
  handleStart (e, data) {
    console.log('移動開始', e, data)
  }
  handleDrag (e, data) {
    console.log('移動中', e, data)
  }
  handleStop (e, data) {
    console.log('移動終了', e, data)
  }
  render () {
    return <div className="component">
      <label>
        Movable
        <input 
          type="checkbox"
          checked={this.state.movable}
          onChange={() => this.setState({movable: !this.state.movable})}
        />
      </label>
      <Draggable
        handle=".drag-point"
        disabled={!this.state.movable}
        defaultPosition={{x: 0, y: 0}}
        onStart={this.handleStart}
        // ログが大量に出力されるためコメントアウト
        //onDrag={this.handleDrag}
        onStop={this.handleStop}
        // 移動範囲を相対座標で制限
        bounds={{top:0, bottom: 100, left: 0, right: 500}}
      ><div className="tellk">
        <img 
          className={this.state.movable ? 'movable': ''}
          src={tellK} width="100"
        />
        <div
          className="drag-point"
          style={{
            width: 20, height: 20,
            position: 'absolute', top: 42, left: 42,
            cursor: "move",
            display: this.state.movable ? 'block' : 'none',
            backgroundColor: '#000', opacity: 0.1,
          }}
        />
      </div></Draggable>
    </div>
  }
}
styles/react-draggable.styl
.tellk
  position relative
  width 100px
  z-index 2

ちょっと説明が難しいんですが、 フリーカーソル的 な 自由移動なので、 ドラッグして順番を入れ替えるような用途では後述する react-dnd が便利です。

(もちろん頑張ればできると思いますが)

react-dnd

要素をドラッグアンドドロップ (Drag and Drop) するためのライブラリです。

前述した react-rnd と名前も用途も似ていますが、 始点と終点の制御を細かく設定できるのが特徴です。

要素のソート等にも適しています。

バージョン
5.0.1
リポジトリ
https://github.com/react-dnd/react-dnd
デモ
  • 数字を並び替えるゲームを実装してみました。よくしらないですが、15ゲーム(?)というらしいです。 デフォルトだと 3x3 なので 多分 8ゲームです
  • 空白のセルに退避しつつ数字を左上から順に並びかえるだけの単純なルールです
  • 正しい場所に来ると 色がつきます
  • ランダムで配置してます。
  • PC でしか動きません。
コード
components/react-dnd.js
import React from 'react';
import HTML5Backend from 'react-dnd-html5-backend'
import {
  DragSource,
  DropTarget,
  DragDropContext,
  // DragDropContextProvider,
} from 'react-dnd';

import '@styles/react-dnd.styl'

const DEFAULT_SIZE = 3

const range = (start, end) => {
  if (!end) {
    end = start
    start = 0
  }
  return [... Array(end).keys()].slice(start)
}

const getArray = number => {
  return range(number * number - 1).concat([null])
}

const getRandomArray = number => {
  const a = getArray(number)
  return a.sort(() => Math.random() > Math.random() ? 1 : -1)
}

const getRandomMatrix = number => {
  const a = getRandomArray(number)
  return range(number).map(i => a.slice(i * number, i * number + number))
}

const dragSourceWrapper = DragSource(
  'game', // type
  {
    beginDrag(props) {
      // monitor.getItem() のオブジェクトの属性を定義する
      return {x: props.x, y: props.y, asis: props.asis}
    },
    endDrag (props, monitor) {
      const item = monitor.getItem()
      const dropResult = monitor.getDropResult()

      if (dropResult && props.swappable(props, dropResult)) {
        props.swap(props, dropResult)
        console.log(`You dropped ${item.asis + 1} into (${dropResult.x + 1}, ${dropResult.y + 1})`)
      }
    }
  }, 
  (connect, monitor) => ({
    connectDragSource: connect.dragSource(),
    isDragging: monitor.isDragging(),
  })
)


const Num = dragSourceWrapper(class Num extends React.Component {
  render () {
    const { connectDragSource, asis, tobe } = this.props
    if (asis === null) {
      return null
    }
    return connectDragSource(
      <div className={`ball ${asis === tobe ? 'match' : ''}`}>{asis + 1}</div>
    )
  }
})


const dropTargetWrapper = DropTarget(
  'game', 
  {
    canDrop (props, monitor) {
      // ここにドロップ可能か?
      const ball = monitor.getItem()
      return props.swappable(ball, props)
    },
    drop (props) {
      // ドロップされたら自身の座標を返す
      return {x: props.x, y: props.y}
    }
  }, 
  (connect, monitor) => ({
    connectDropTarget: connect.dropTarget(),
    isOver: monitor.isOver(),
    canDrop: monitor.canDrop(),
  }),
)

const Cell = dropTargetWrapper(class Cell extends React.Component {
  render () {
    const { 
      connectDropTarget, children,
      isOver, canDrop,
    } = this.props
    const isActive = canDrop && isOver
    return connectDropTarget(
      <td className={`cell ${isActive ? 'active' : ''}`}>
        {children}
      </td>
    )
  }
})

const contextWrapper = DragDropContext(HTML5Backend)

export default contextWrapper(class Component extends React.Component {
  constructor(props) {
    super(props)
    this.state = this.makeState(DEFAULT_SIZE)
  }
  makeState (size) {
    return {
      size,
      cells: getRandomMatrix(size),
      count: 0,
    }
  }
  finished () {
    return getArray(this.state.size).toString() === this.state.cells.toString()
  }
  swap (a, b) {
    // セルの値を交換する
    const {cells} = this.state
    const av = cells[a.y][a.x]
    const bv = cells[b.y][b.x]
    cells[b.y][b.x] = av
    cells[a.y][a.x] = bv
    this.setState({
      cells: [... cells],
      count: this.state.count + 1,
    })
  }
  swappable (a, b) {
    const {cells} = this.state
    // b は空であること
    if (cells[b.y][b.x] !== null) {
      return false
    }
    const xdis = Math.abs(a.x - b.x)
    const ydis = Math.abs(a.y - b.y)
    // 隣り合っていること
    return (xdis == 0 && ydis == 1) || (xdis == 1 && ydis == 0)
  }

  render () {
    return <div className="component">
      試行回数: {this.state.count}
      <table className="numbers">
        <tbody>
          {
            this.state.cells.map((row, y) =>
              <tr key={y}>
                {
                  row.map((col, x) => (
                    <Cell 
                      key={`${x},${y}`} 
                      y={y} 
                      x={x}
                      swappable={this.swappable.bind(this)}
                    >
                      <Num
                        y={y}
                        x={x}
                        asis={col}
                        tobe={y * this.state.size + x}
                        swap={this.swap.bind(this)}
                        swappable={this.swappable.bind(this)}
                      />
                    </Cell>
                  ))
                }
              </tr>
            )
          }
        </tbody>
      </table>
      <p>{this.finished() ? 'よくできました🎉' : 'がんばって!'}</p>

      サイズ変更: <select 
        className="size-modifier"
        defaultValue={DEFAULT_SIZE}
        onChange={ e => {
          const newSize = parseInt(e.target.value)
          this.setState(this.makeState(newSize))
        }}
      >
      {
        range(3, 10).map(i => <option key={i} value={i}>{i}</option>)
      }
      </select>
    </div>
  }
})
styles/react-dnd.styl
.ball
  width 30px
  height 30px
  line-height 30px
  text-align center
  border solid 2px #000000
  border-radius 50%
  cursor move
  margin 10px

  &.match
    background-color #dfd

.cell
  width 50px
  height 50px

  &.active
    background-color #ffd

.numbers
  width auto !important
  border-collapse collapse
  td
    border solid 1px #888

  &.active
    background-color #dedede

.size-modifier
  margin-top 20px

react-dnd は 他のライブラリに比べて少し概念的にむずかしいです。 あとコピペで動くサンプルがなかなかみつからなくて辛いです(感想)。

正直自分もちゃんとは理解できてないと思ってますが とりあえず解説します。

このライブラリは 以下の 3つの概念から成り立っています。

Drag source
  • ドラッグして動かしたい対象
  • DragSource クラスで動かす対象の動きを定義し、その定義で動かす対象のコンポーネントをラップする
    • 今回は beginDrag と endDrag を定義
Drop target
  • ドロップする対象の領域
  • DropTarget クラスで ドロップ領域の動作や制限を定義し、その定義で領域のコンポーネントをラップする
    • 今回は canDrop と drop を定義
      • canDrop がドロップ時の制限にあたる (true or false を返却する)
      • drop が 領域情報を返却する
Drag and Drop context
  • 上記2つを仲介するコンポーネント領域
  • 公式では DragDropContextProvider で要素をラップするような例が記述されてるんですが、 今回は DragDropContext(HTML5Backend) で作ったラッパーで コンポーネントをラップしてます
    • 理由としては (この場合はサイズ変更で) 要素が増えたときに Cannot have two HTML5 backends at the same time というエラーが発生します。 これは HTML5Backend というオブジェクトが同時に複数作成されることが原因のようです。
      • 別の回避策としては、一度対象のDOMを消して(Unmount) してから再描画するとうまくいきます
        • 今回だと this.state.cells[] にして unmount されたあとに再描画するとうまくいきます。
          • this.setState のコールバックで呼び出す

備考

ES7 の公式テキストでは 上記のラッパーをデコレータ で適用するような例が書いてありましたが、 同じようにしたところ props から得られる connect 関連のオブジェクトがうまく受け取れずエラーになってしまい、 仕方なく、一旦変数に落としてからラップしてます。

やってることは同じはずなんですが原因がわかりません。優しい人は教えてください。

参考

終わりに (Conclusion)

いかがだったでしょうか。役に立ちそうなライブラリは見つかりましたか?

他にも有用なライブラリはたくさんあると思いますが、時間の都合上今回はここまでということで。 次回があるかはわかりませんが、リクエストをいただけたらとりあげるかもしれません。

今回使ったコードは ZIPファイル にしたので 手元で試したい人は解凍して自由に改変してください。

npm install; npm run build すると js がビルドされるので その後 demo.html を開くと 閲覧できます。