0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2023-06-23

参考記事

はじめに

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

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

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

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

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

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

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

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

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 のプレゼンテーショナルコンポーネントだけ作成しておきます。

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 を作成するようにしておきます。

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 を拡張可能にしておいたので、必要であれば定義を追加します。

使用側のソース
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

やり残していること

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

参考文献

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

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?