参考記事
はじめに
学習も兼ねて React と MUI を使用してテーブルを作成していた時のことです。
テーブルコンポーネントを「プレゼンテーショナルコンポーネント」と「コンテナコンポーネント」に分けて作成したくなりました。
でも、 Table, TableHead, TableBody, TableRow, TableCell といったテーブル用のコンポーネントの全てでプレゼンテーショナルコンポーネントを用意するのは面倒くさいし、果たして使いやすいのだろうか?
かと言って、ひとつのプレゼンテーショナルコンポーネントでTableからTableCellまで全部を使用してしまうと拡張性に乏しいし。
と考えて、 Vue.js のようにスロットをテーブルに用意することで、シンプルに使えそうな感じにしてみました。
スタイリングには tailwindcss を利用しています。
スロットを追加する仕組みを作成する
この部分は前述のZennの記事を参考にしています。
create
メソッドでSlot用のダミーコンポーネントを作成し、find
メソッドでchildrenから目的のコンポーネントメソッドを探すことができるようにします。
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 のプレゼンテーショナルコンポーネントだけ作成しておきます。
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 を作成するようにしておきます。
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>
)
出来上がりイメージ
やり残していること
まだまだ勉強不足なので、 Item スロットから取得される props が型推測できるようになっていないのが課題です。
そも出来るのかすら分かっていないのですが。
可能なら何とかしたい。。
参考文献
この記事は以下の情報を参考にして執筆しました。