React Spreadsheet でセレクトボックスを扱う (React Select 使用)

経緯

表題の通りですが、 React Spreadsheet でセレクトボックスを扱いたいと考えました。

そもそものところですが、「 React アプリケーション上でスプレッドシートを扱いたい、かつ、そのアプリケーションのスプレッドシートでドロップダウン式の選択項目を作りたい」という個人的需要があり、それに対して試行錯誤する中で実装できたのが React Spreadsheet + React Select の組み合わせでした。

成果物

早速成果物としてデモのコードを。

スプレッドシート本体

スプレッドシート本体部分 (セレクトボックスのコンポーネントを除く) のコードは以下。

やっていることは

  • サンプルデータでスプレッドシートを作成 (アプリ起動時に useEffectuseState にデータを保存、保存されたタイミングでスプレッドシートを描画) 。
  • セレクトボックスのコンポーネントへデータを渡すために useContext 使用。

という辺り。実際には1個目の処理が AJAX で API からデータをもらう部分になるのではないでしょうか。

import { useState, useEffect, createContext } from 'react';
import { Spreadsheet, CellBase, createEmptyMatrix, Matrix } from 'react-spreadsheet';
import { SelectComponentGenius } from './SelectComponentGenius';
import { SelectComponentConway } from './SelectComponentConway';
import { SelectComponentBeryl } from './SelectComponentBeryl';
import { SelectComponentShionne } from './SelectComponentShionne';
import { SelectComponentRinwellI } from './SelectComponentRinwellI';
import { SelectComponentRinwellA } from './SelectComponentRinwellA';
import { SelectComponentRubiaI } from './SelectComponentRubiaI';
import { SelectComponentRubiaA } from './SelectComponentRubiaA';

// React Select に選択肢の値を持って行くために useContext 使用
export const Params = createContext({});
// Cell 定義
type Cell = CellBase<string | number | undefined>;

// React Select に渡すには value, label 2つのキーを持ったオブジェクトの型でなければいけないため、その定義
type SelectCell = {
    value: string,
    label: string,
}

// データの構造定義
type Acuirers = {
    acuirers: string,
    magic: {
        beginner: string,
        intermediate: string|SelectCell[],
        advanced: string|SelectCell[],
    },
};

// サンプルデータ
const acuirersData: Acuirers[] = [
    {
        acuirers: 'キール',
        magic: {
            beginner: 'アクアエッジ',
            intermediate: 'スプレッド',
            advanced: 'メイルシュトローム',
        },
    },
    {
        acuirers: 'ジーニアス',
        magic: {
            beginner: 'アクアエッジ',
            intermediate: [
                {
                    value: 'スプレッド',
                    label: 'スプレッド',
                },
                {
                    value: 'アクアレイザー',
                    label: 'アクアレイザー',
                },
            ],
            advanced: 'タイダルウェイブ',
        },
    },
    {
        acuirers: 'ウィル',
        magic: {
            beginner: '',
            intermediate: 'スプレッド',
            advanced: '',
        },
    },
    {
        acuirers: 'イリア',
        magic: {
            beginner: 'アクアエッジ',
            intermediate: 'スプレッド',
            advanced: 'タイダルウェイブ',
        },
    },
    {
        acuirers: 'コンウェイ',
        magic: {
            beginner: 'アイスニードル',
            intermediate: 'スプレッド',
            advanced: [
                {
                    value: 'メイルシュトローム',
                    label: 'メイルシュトローム',
                },
                {
                    value: 'インブレイスエンド',
                    label: 'インブレイスエンド',
                },
            ],
        },
    },
    {
        acuirers: 'ヒスイ',
        magic: {
            beginner: 'アイスニードル',
            intermediate: 'スプレッド',
            advanced: 'アクアゲイザー',
        },
    },
    {
        acuirers: 'ベリル',
        magic: {
            beginner: 'アイスニードル',
            intermediate: 'スプレッド',
            advanced: [
                {
                    value: 'アクアゲイザー',
                    label: 'アクアゲイザー',
                },
                {
                    value: 'インブレイスエンド',
                    label: 'インブレイスエンド',
                },
            ],
        },
    },
    {
        acuirers: 'パスカル',
        magic: {
            beginner: 'スプレッド',
            intermediate: 'メイルシュトローム',
            advanced: 'シアンディーム',
        },
    },
    {
        acuirers: 'シオン',
        magic: {
            beginner: 'スプレッド',
            intermediate: [
                {
                    value: 'フリーズランサー',
                    label: 'フリーズランサー',
                },
                {
                    value: 'アイストルネード',
                    label: 'アイストルネード',
                },
            ],
            advanced: '',
        },
    },
    {
        acuirers: 'リンウェル',
        magic: {
            beginner: 'スプレッド',
            intermediate: [
                {
                    value: 'フリーズランサー',
                    label: 'フリーズランサー',
                },
                {
                    value: 'アローレイン',
                    label: 'アローレイン',
                },
            ],
            advanced: [
                {
                    value: 'タイダルウェーブ',
                    label: 'タイダルウェーブ',
                },
                {
                    value: 'メイルシュトローム',
                    label: 'メイルシュトローム',
                },
            ],
        },
    },
    {
        acuirers: 'ルビア',
        magic: {
            beginner: 'アイシクル',
            intermediate: [
                {
                    value: 'スプレッド',
                    label: 'スプレッド',
                },
                {
                    value: 'アイストーネード',
                    label: 'アイストーネード',
                },
            ],
            advanced: [
                {
                    value: 'アブソリュート',
                    label: 'アブソリュート',
                },
                {
                    value: 'インブレイスエンド',
                    label: 'インブレイスエンド',
                },
            ],
        },
    },
];

export const SpreadComponents = (): JSX.Element => {
    // 列ラベル
    const columnLabels = ['習得者', '初級術', '中級術', '上級術'];
    // 初期値として空データの定義
    const EMPTY_DATA = createEmptyMatrix<Cell>(1, columnLabels.length);
    // データ
    const [resultAcuirers, setResultAcuirers] = useState(EMPTY_DATA);
    // React Spreadsheet のセレクトボックス用の引数作成 (useContext)
    const args = {
        genius: acuirersData[1].magic.intermediate,
        conway: acuirersData[4].magic.advanced,
        beryl: acuirersData[6].magic.advanced,
        shionne: acuirersData[8].magic.intermediate,
        rinwell_i: acuirersData[9].magic.intermediate,
        rinwell_a: acuirersData[9].magic.advanced,
        rubia_i: acuirersData[10].magic.intermediate,
        rubia_a: acuirersData[10].magic.advanced,
    };

    const getData = (): void => {
        // acuirers データ取得・整形, React Spreadsheet のセル作成
        let acuirers: any = [];
        let acuirersMatrix: Matrix<Cell>;
        if(acuirersData.length > 0) {
            for (const v of acuirersData) {
                let DataEditorIntermediate;
                let DataEditorAdvanded;
                // 共通の術名の組み合わせがなく全員バラバラだったので、雑にそれぞれ固有のセレクトボックスのコンポーネントを作成してそれを利用するようにした
                switch (v.acuirers) {
                    case 'ジーニアス':
                        DataEditorIntermediate = SelectComponentGenius;
                        break;
                    case 'コンウェイ':
                        DataEditorAdvanded = SelectComponentConway;
                        break;
                    case 'ベリル':
                        DataEditorAdvanded = SelectComponentBeryl;
                        break;
                    case 'シオン':
                        DataEditorIntermediate = SelectComponentShionne;
                        break;
                    case 'リンウェル':
                        DataEditorIntermediate = SelectComponentRinwellI;
                        DataEditorAdvanded = SelectComponentRinwellA;
                        break;
                    case 'ルビア':
                        DataEditorIntermediate = SelectComponentRubiaI;
                        DataEditorAdvanded = SelectComponentRubiaA;
                        break;

                    default:
                        break;
                }
                acuirers.push([
                    // 習得者 (読み取り専用)
                    {
                        value: v.acuirers,
                        readOnly: true
                    },
                    // 初級術 (文字列・編集可)
                    {
                        value: v.magic.beginner,
                    },
                    // 中級術 (文字列または React Select によるセレクトボックスでの選択、編集可)
                    Array.isArray(v.magic.intermediate)
                        ? {
                            value: '選択してください',
                            readOnly: false,
                            DataEditor: DataEditorIntermediate,
                            className: 'select-cell',
                        }
                        : {
                            value: v.magic.intermediate,
                        },
                    // 上級術 (文字列または React Select によるセレクトボックスでの選択、セレクトボックスの場合のみ編集可)
                    Array.isArray(v.magic.advanced)
                        ? {
                            value: '選択してください',
                            readOnly: false,
                            DataEditor: DataEditorAdvanded,
                            className: 'select-cell',
                        }
                        : {
                            value: v.magic.advanced,
                            readOnly: true,
                        },
                ]);
            }
        }
        // Cell の Matrix 型にしないと Spreadsheet コンポーネントの onChange に引っかけられないので変換
        acuirersMatrix = acuirers;
        // useState で set
        setResultAcuirers(acuirersMatrix);
    };

    useEffect(() => {
        // コンポーネントの初期化時にのみ実行 // アプリ起動時のみ実行の意図
        getData();
    }, []);

    return (
        <Params.Provider value={args}>
            <Spreadsheet
                data={resultAcuirers}
                columnLabels={columnLabels}
                onChange={setResultAcuirers}
            />
        </Params.Provider>
    );
};

セレクトボックスのコンポーネント

一方セレクトボックスのコンポーネントは以下のような感じ。

  • セルの定義。
  • useContext で受け取ったデータを基にセレクトボックスを生成。
  • 値変更の挙動時に useCallback で処理実行。
  • 状態記憶に useMemo 使用。

辺りを実装している感じでしょうか。

import { useMemo, useCallback, useContext } from 'react';
import { CellBase, DataEditorComponent } from 'react-spreadsheet';
import Select from 'react-select';
import { Params } from './SpreadComponents';

// 型定義
type Value = string | number | undefined;
type Cell = CellBase<Value> & {
  value: Value;
};

export const SelectComponentGenius: DataEditorComponent<Cell> = ({
    cell,
    onChange,
    exitEditMode,
  }) => {
    const { genius }: any = useContext(Params);

    const handleChange = useCallback(
      (selection: any) => {
        onChange({ ...cell, value: selection ? selection.value : null });
      },
      [cell, onChange]
    );
    const option = useMemo(
      () => cell && genius.find((option: any) => option.value === cell.value),
      [cell]
    );
    return (
      <Select
        value={option}
        onChange={handleChange}
        options={genius}
        autoFocus
        defaultMenuIsOpen
        onMenuClose={() => exitEditMode()}
      />
    );
};

以上のようなコードで実装できました。

動作確認

React Spreadsheet + React Select のサンプルアプリ
React Spreadsheet + React Select のサンプルアプリ

画面全体。単純にスプレッドシートを描画しています。

React Spreadsheet 内の React Select で要素を選択している図1
React Spreadsheet 内の React Select で要素を選択している図1

選択式のセルでダブルクリックすると選択肢が展開されて予め決めておいた要素を選択することができます。

React Spreadsheet 内の React Select で要素を選択している図2
React Spreadsheet 内の React Select で要素を選択している図2

先程とは別のセルで同様に選択。きちんと選択できていることも分かります。

余談 (データの内容)

今回はスプレッドシートということでそれなりの個数のデータが欲しかったので、「スプレッド」シートに関するものということでテイルズシリーズの魔法(作品によって設定や名称は異なりますが)で「スプレッド」という水を地面から噴出させるものがあったので、歴代の「スプレッド」を習得するキャラの一覧にしました。

※ただ、全部の作品をプレイしたわけではないので初級・中級・上級の区分が異なっていたり、氷属性と水属性がゲーム内では異なる属性として設定されているのに一緒くたにしていたり、データに誤りがある可能性があります。

参考

React

起動時のみ実行

スプレッド的なサムシング

React Spreadsheet

セル内にセレクトボックスで選択式にしたい

Storybook を見ると使えることは分かる。ただ、どうやって実装しているかが見えない。

コンポーネントとしてはこれを真似すれば行けそう。ただ、 Spreadsheet コンポーネントの1セル内にどう埋め込むのかが分からない。また、選択肢をハードコーディングではなく変数利用したい。できれば汎用的な作りにしたい。

データを渡すときに DataEditor キーでコンポーネントを食わせれば良さそう。

引数を渡すには……いや、なさそう。

Redux, reducer までは使いたくなかったので useContext でごまかした。

セルの値が変更されたらその値を取得したい、という場合は Spreadsheet コンポーネントに onChenge を付ける。ただし型が Matrix<CellBase> であること、基本的に useState を使うであろうことから、 useState でセットする値を予め Matrix<CellBase> にしておく必要がある。

props

型 ‘string’ を型 ‘never’ に割り当てることはできません。

型 ‘string’ を型 ‘never’ に割り当てることはできません。

のようなエラー。

useState の初期値指定のときに配列ならば型指定(何の配列か)を指定すれば良い。

If you meant to render a collection of children, use an array instead.

If you meant to render a collection of children, use an array instead.

Warning: Cannot update a component (Connect(Form(Change Form))) while rendering a different component (Change Form).

Warning: Cannot update a component (Connect(Form(Change Form))) while rendering a different component (Change Form).

TypeScript

オブジェクトの型定義

interface

多次元配列

Cannot read properties of undefined (reading ‘push’), 変数 {hoge} は割り当てられる前に使用されています

この記事を書いた人

アルム=バンド

フロントエンド・バックエンド・サーバエンジニア。LAMPやNodeからWP、Gulpを使ってejs,Scss,JSのコーディングまで一通り。たまにRasPiで遊んだり、趣味で開発したり。