ReactでVuetifyライクなSlot付きテーブルコンポーネントを作成してみる

## 参考記事
https://zenn.dev/wintyo/articles/f2c4f6a11cf418

## はじめに
学習も兼ねて React と MUI を使用してテーブルを作成していた時のことです。

テーブルコンポーネントを「プレゼンテーショナルコンポーネント」と「コンテナコンポーネント」に分けて作成したくなりました。

でも、 Table, TableHead, TableBody, TableRow, TableCell といったテーブル用のコンポーネントの全てでプレゼンテーショナルコンポーネントを用意するのは面倒くさいし、果たして使いやすいのだろうか？
かと言って、ひとつのプレゼンテーショナルコンポーネントでTableからTableCellまで全部を使用してしまうと拡張性に乏しいし。

と考えて、 Vue.js のようにスロットをテーブルに用意することで、シンプルに使えそうな感じにしてみました。

スタイリングには tailwindcss を利用しています。

## スロットを追加する仕組みを作成する

この部分は前述のZennの記事を参考にしています。

`create`メソッドでSlot用のダミーコンポーネントを作成し、`find`メソッドでchildrenから目的のコンポーネントメソッドを探すことができるようにします。

```typescript:Slot.ts
import type { FC, ReactElement } from 'react'

type ReactChild = ReactElement | string | number
type PropsChildren = ReactChild[] | ReactChild | ((...params: any[]) => ReactChild)
type SlotProps<Children extends PropsChildren = ReactChild> = {
  children: Children
}

export type SlotComponent<Children extends PropsChildren = ReactChild> = FC<SlotProps<Children>>

export const useSlot = <Children extends PropsChildren = ReactChild>() => ({
  create: (): SlotComponent<Children> => {
    return () => null
  },
  find: (
    children: ReactElement[] | ReactElement | undefined,
    slot: SlotComponent<Children>,
  ): Children | undefined => {
    if (Array.isArray(children)) {
      const child = children.find((child) => child.type === slot)
      return child && (child.props.children as Children)
    }
    if (children?.type === slot) {
      return children.props.children as Children
    }
    return undefined
  },
})
```

## プレゼンテーショナルコンポーネントを作る

スロットができたので、テーブルのプレゼンテーショナルコンポーネントを作成します。
（プレゼンテーショナルコンポーネントと言うわりに、デザインに関する部分は作り込んでいませんが）

### テーブルセル
テーブル用のパーツとしては、 TableCell のプレゼンテーショナルコンポーネントだけ作成しておきます。

``` TSX:SimpleTableCell.tsx
import { TableCell } from '@mui/material'
import type { TableCellProps } from '@mui/material'

export const SimpleTableCell = ({ className, children, ...props }: TableCellProps) => (
  <TableCell {...props} className={`border-divider ${className || ''}`}>
    {children}
  </TableCell>
)

```

### テーブル本体
ここでは Vuetify の v-data-table を意識して、Item 等のスロットを追加しています。

更に、 Item スロットを使用しないケースも想定して、スロットが存在しない場合はヘッダアイテムのキーと一致するデータのみ td を作成するようにしておきます。

``` TSX:SimpleTable.tsx
import { Paper, Table, TableBody, TableContainer, TableHead, TableRow } from '@mui/material'
import { SimpleTableCell } from './SimpleTableCell'
import { slotComponent } from '~/components/common/Slot'
import type { ReactNode, ReactElement } from 'react'

export type SimpleTableProps<TItem extends Record<string, any>> = {
  head: {
    items: {
      key?: string
      children: ReactNode
      className?: string
    }[]
    className?: string
  }
  body?: {
    className?: string
  }
  items: TItem[]
  total?: number
  noDataText?: string
  children?: ReactElement[] | ReactElement
  className?: string
  onClick?: (e: { origin: TItem; key?: TItem[keyof TItem] }) => void
}

const NoDataTableRow = <TItem extends Record<string, any>>({
  colSpan,
  text,
  slot,
}: {
  colSpan: number
  text?: string
  slot?: (props: { item: TItem }) => ReactElement
}) => {
  return (
    <TableRow>
      <SimpleTableCell colSpan={colSpan} className="text-center">
        {slot ? (
          slot({ item: {} as TItem })
        ) : (
          <p className="text-gray-400">{text || 'no record found'}</p>
        )}
      </SimpleTableCell>
    </TableRow>
  )
}

const SimpleTableRow = <TItem extends Record<string, any>>({
  head,
  body,
  item,
  onClick,
  slot,
}: Pick<SimpleTableProps<TItem>, 'head' | 'body' | 'onClick'> & {
  item: TItem
  slot?: (props: { item: TItem }) => ReactElement
}) => {
  return (
    <TableRow className={body?.className} onClick={() => !!onClick && onClick({ origin: item })}>
      {(() => {
        if (slot) {
          return slot({ item })
        }

        const headKeys = head.items.reduce((prev, { key }) => {
          if (key) prev.push(key)
          return prev
        }, [] as string[])

        return Object.entries(item).reduce((prev, [key, value], cellIndex) => {
          if (!headKeys.includes(key)) return prev
          prev.push(<SimpleTableCell key={`c-${cellIndex}`}>{value?.toString() ?? ''}</SimpleTableCell>)
          return prev
        }, [] as ReactElement[])
      })()}
    </TableRow>
  )
}

const createSimpleTable = <TItem extends Record<string, any>>() => {
  const slot = slotComponent<(props: { item: TItem }) => ReactElement>()
  const ItemSlot = slot.create()
  const NoDataSlot = slot.create()

  return {
    slot: { item: ItemSlot, noData: NoDataSlot },
    root: ({
      head,
      body,
      items,
      total,
      noDataText,
      children,
      onClick,
    }: SimpleTableProps<TItem>) => {
      const itemSlot = slot.find(children, ItemSlot)
      const noDataSlot = slot.find(children, NoDataSlot)

      return (
        <TableContainer component={Paper} elevation={0} square>
          <Table className="table-fixed">
            <TableHead className="border-solid border-0 border-t border-divider">
              <TableRow className={head.className}>
                {head.items.map(({ key, ...cellData }, cellIndex) => (
                  <SimpleTableCell key={`h-${key || cellIndex}`} {...cellData} />
                ))}
              </TableRow>
            </TableHead>
            <TableBody>
              {(total ?? items.length) === 0 ? (
                <NoDataTableRow slot={noDataSlot} text={noDataText} colSpan={head.items.length} />
              ) : (
                items.map((item, index) => (
                  <SimpleTableRow
                    key={`r-${index}`}
                    head={head}
                    body={body}
                    item={item}
                    onClick={onClick}
                    slot={itemSlot}
                  />
                ))
              )}
            </TableBody>
          </Table>
        </TableContainer>
      )
    },
  }
}

const simpleTable = createSimpleTable()

export const SimpleTable = Object.assign(simpleTable.root, {
  Item: simpleTable.slot.item,
  NoData: simpleTable.slot.noData,
})

```

## コンテナコンポーネントを作る

業務ロジック等は全てこちらに寄せます。
各プレゼンテーショナルコンポーネントでは ClassName を拡張可能にしておいたので、必要であれば定義を追加します。

```TSX:使用側のソース
const head = {
  items: [
    { children: 'Cell1 & Cell2', className: 'w-[60%]' },
    { children: 'Cell3', className: 'w-[40%]' },
  ],
}

const items = [
  { cell1: 'Row1', cell2: 'Row1', cell3: 'Row1' },
  { cell1: 'Row2', cell2: 'Row2', cell3: 'Row2' },
]

return (
  <SimpleTable head={head} items={items}>
    <SimpleTable.Item>
      {({ item }) => {
        const { cell1, cell2, cell3 } = item as (typeof items)[0]
        return (
          <>
            <SimpleTableCell>
              {`${cell1} & ${cell2}`}
            </SimpleTableCell>
            <SimpleTableCell>
              {cell3}
            </SimpleTableCell>
          </>
        )
      }}
    </SimpleTable.Item>
    <SimpleTable.NoData>
      {() => (
        <p className="text-gray-400">
          データが登録されていません
          <br />
          新規登録処理を実行してください
        </p>
      )}
    </SimpleTable.NoData>
  </SimpleTable>
)
```

### 出来上がりイメージ
![スクリーンショット 2023-06-19 17.41.22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/333070/629affc9-342d-f288-1c61-fd39b6100f3e.png)


## やり残していること
まだまだ勉強不足なので、 Item スロットから取得される props が型推測できるようになっていないのが課題です。
そも出来るのかすら分かっていないのですが。
可能なら何とかしたい。。

## 参考文献

この記事は以下の情報を参考にして執筆しました。

- [ReactでVue.jsのslot的なことをする](https://zenn.dev/wintyo/articles/f2c4f6a11cf418)