はじめに
テーブルを利用して何かしらの一覧画面を作成する際、Y軸スクロール時にヘッダーを常駐させたくなることがあるかと思います。
それも、table 内のスクロールではなく、body 内のスクロールに対して常駐させたくなることが。
何故かというと、table 内のスクロールでは画面全体のスクロールと table のスクロールでスクロールが二重になってしまうからです。
例えば、MUI の Table で stickyHeader を指定する事でもヘッダーの常駐は可能ですが、どうもTableContainerに対して常駐させる機能の様ですので、掲題の機能を完全には実現出来ません。(だぶん)
このような動作を実現しようとする場合は、table-header のみの table と table-body のみの table を準備してX軸スクロールを同期させるしかなさそうです。
おそらくは、これが一般的な方法なのではないかと思います。
なので、その方法をまとめておきたいと思います。
方針
ある程度、汎用的に使用できる Table.tsx を作成して機能の実現を図っていきたいと思います。
今回は React + MUI で作成しますが、ヘッダーの常駐は style で実現していくので、他の構成でも流用できるかと思います。
その他、以下の機能も盛り込んでいきます。
- 行の左側に選択用のチェックボックスを表示する
- テーブルヘッダーにソートアイコンを表示する
実装
型の定義
まず必要な型定義を行います。
import type { ReactNode } from 'react'
// ソート情報の型定義
export type Order = 'asc' | 'desc'
// Tableに渡すデータのgenerics型定義の基底部
export type TDataBase = {
id: number // コンポーネントkey用のID
}
// Tableに渡すヘッダー情報の型定義
export type HeaderItem<TData extends TDataBase> = {
id: keyof TData // コンポーネントkey用のID
label: ReactNode
width: string | number // headerとbodyが分断されてしまう為、カラム幅を明示して固定にする
sticky?: // 列カラム常駐用の定義。左右両方を想定する
| {
left: string | number
}
| {
right: string | number
}
}
hooks の定義
Ref を使用してテーブルヘッダーとテーブルボディのX軸スクロールを同期させる処理を hooks に隠蔽しておきます。
各 Ref が定義された Dom が存在しない内に useEffect が実行されると Ref の同期が上手くいかないので引数の isReady で制御します。
import { useEffect, useRef } from 'react'
interface Args {
isReady: boolean
}
export const useSyncedHorizontalScroll = ({ isReady }: Args) => {
const scrollTargetRef = useRef<HTMLDivElement>(null)
const syncedTargetRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!isReady) return
const syncedTarget = syncedTargetRef.current
const scrollTarget = scrollTargetRef.current
if (!syncedTarget || !scrollTarget) return
const onScroll = () => {
syncedTarget.scrollLeft = scrollTarget.scrollLeft
}
scrollTarget.addEventListener('scroll', onScroll)
return () => scrollTarget.removeEventListener('scroll', onScroll)
}, [isReady])
return {
scrollTargetRef,
syncedTargetRef,
}
}
テーブルの定義
ルートコンポーネントとなるTableを作成します。
ここでは useSyncedHorizontalScroll を利用して Ref を取得し、TableBody と TableHeader に配布するだけです。
import { CircularProgress, TablePagination } from '@mui/material'
import { useSyncedHorizontalScroll } from 'useSyncedHorizontalScroll'
import type { HeaderItem, Order, TDataBase } from 'types'
import { TableBody } from 'TableBody'
import { TableHeader } from 'TableHeader'
export type TableProps<TData extends TDataBase> = {
isLoading?: boolean
header: HeaderItem<TData>[]
data: TData[]
total: number
selected?: TData[]
setSelected?: (selected: TData[]) => void
paginationParams: {
page: number
perPage: number
}
onPaginationChange: (params: {
page: number
perPage: number
}) => void
order?: Order
setOrder?: (order: Order) => void
orderBy?: keyof TData
setOrderBy?: (key: keyof TData) => void
}
export const Table = <TData extends TDataBase>({
isLoading = false,
header,
data,
total,
selected,
setSelected,
paginationParams,
onPaginationChange,
order,
setOrder,
orderBy,
setOrderBy,
}: TableProps<TData>) => {
const { scrollTargetRef, syncedTargetRef } = useSyncedHorizontalScroll({
isReady: !isLoading && total > 0,
})
if (isLoading) {
return (
<div style={{ padding: 16, textAlign: 'center' }}>
<CircularProgress />
</div>
)
}
if (total == 0) {
return (
<p>
no record
</p>
)
}
return (
<>
<TableHeader
ref={syncedTargetRef}
header={header}
data={data}
selected={selected}
setSelected={setSelected}
order={order}
orderBy={orderBy?.toString()}
onRequestSort={(_event, property) => {
const isAsc = orderBy === property && order === 'asc'
setOrder?.(isAsc ? 'desc' : 'asc')
setOrderBy?.(property)
}}
/>
<TableBody
ref={scrollTargetRef}
header={header}
data={data}
selected={selected}
setSelected={setSelected}
/>
<TablePagination
component="div"
count={total}
page={paginationParams.page - 1}
onPageChange={(_event, newPage) => {
onPaginationChange({
...paginationParams,
page: newPage + 1
})
}}
rowsPerPage={paginationParams.perPage}
onRowsPerPageChange={(event) => {
onPaginationChange({
...paginationParams,
perPage: parseInt(event.target.value, 10)
})
}}
/>
</>
)
}
続いて Table コンポーネントで使用する TableHeader と TableBody を作成します。
行選択用のチェックボックスは props.selected が指定された場合に限り表示させます。
ソート機能のソースは MUI のガイドそのままとなっています。
ソート機能は props.order, props.orderBy が指定された場合に限り有効化する想定です。
ソート処理は外部で実行され、 props.data に反映される想定です。
import {
Table,
TableContainer,
TableHead,
TableRow,
TableCell,
Checkbox,
TableSortLabel,
Box,
} from '@mui/material'
import { visuallyHidden } from '@mui/utils'
import React from 'react'
import type { HeaderItem, Order, TDataBase } from 'types'
export type TableHeaderProps<TData extends TDataBase> = {
header: HeaderItem<TData>[]
data: TData[]
order?: Order
orderBy?: string
selected?: TData[]
setSelected?: (selected: TData[]) => void
onRequestSort: (
event: React.MouseEvent<unknown>,
property: keyof TData
) => void
}
export const TableHeader = React.forwardRef(function TableHeader<
TData extends TDataBase
>(
{
header,
data,
order,
orderBy,
selected,
setSelected,
onRequestSort,
}: TableHeaderProps<TData>,
ref: React.ForwardedRef<HTMLDivElement>
) {
return (
<TableContainer
ref={ref}
sx={{
position: 'sticky',
top: '0px',
overflowX: 'hidden',
zIndex: 2,
}}
>
<Table sx={{ whiteSpace: 'nowrap', width: 'auto' }}>
<TableHead>
<TableRow>
{selected && (
<TableCell
padding="checkbox"
onClick={(e) => {
const allSelected = data.every(row => selected.some(s => s.id === row.id))
setSelected?.(allSelected ? [] : data)
e.stopPropagation()
}}
sx={{
position: 'sticky',
minWidth: '48px',
maxWidth: '48px',
zIndex: 1,
left: 0,
backgroundColor: 'grey',
}}
>
<Checkbox
color="primary"
checked={selected.length === data.length}
/>
</TableCell>
)}
{header.map((headCell) => (
<TableCell
key={headCell.id.toString()}
align={'left'}
sx={{
minWidth: headCell.width,
maxWidth: headCell.width,
backgroundColor: 'grey',
...(headCell.sticky
? {
position: 'sticky',
zIndex: 1,
...headCell.sticky,
}
: {}),
}}
>
{order && orderBy ? (
<TableSortLabel
active={orderBy === headCell.id}
direction={orderBy === headCell.id ? order : 'asc'}
onClick={(event) => {
onRequestSort(event, headCell.id)
}}
>
{headCell.label}
{orderBy === headCell.id ? (
<Box component="span" sx={visuallyHidden}>
{order === 'desc'
? 'sorted descending'
: 'sorted ascending'}
</Box>
) : null}
</TableSortLabel>
) : (
headCell.label
)}
</TableCell>
))}
</TableRow>
</TableHead>
</Table>
</TableContainer>
)
}) as <TData extends TDataBase>(
props: TableHeaderProps<TData> & { ref?: React.Ref<HTMLDivElement> }
) => JSX.Element
import {
Table,
TableContainer,
TableRow,
TableCell,
TableBody as MuiTableBody,
Checkbox,
} from '@mui/material'
import React from 'react'
import type { HeaderItem, TDataBase } from 'types'
export type TableBodyProps<TData extends TDataBase> = {
header: HeaderItem<TData>[]
data: TData[]
selected?: TData[]
setSelected?: (selected: TData[]) => void
}
export const TableBody = React.forwardRef(function TableBody<
TData extends TDataBase
>(
{ header, data, selected, setSelected }: TableBodyProps<TData>,
ref: React.ForwardedRef<HTMLDivElement>
) {
return (
<TableContainer ref={ref} sx={{ overflowX: 'scroll' }}>
<Table sx={{ whiteSpace: 'nowrap', width: 'auto' }}>
<MuiTableBody>
{data.map((item) => {
const isSelectedRow = selected?.some((e) => e.id === item.id) ?? false
return (
<React.Fragment key={item.id}>
<TableRow
hover
sx={{
cursor: 'pointer',
backgroundColor: 'white',
'&:hover > td': {
backgroundColor: 'grey',
},
}}
>
{selected && <TableCell
padding="checkbox"
onClick={(e) => {
const newSelected = isSelectedRow
? selected.filter(({ id }) => id !== item.id)
: [...selected, item]
setSelected?.(newSelected)
e.stopPropagation()
}}
sx={{
position: 'sticky',
minWidth: '48px',
maxWidth: '48px',
zIndex: 1,
left: 0,
backgroundColor: 'white',
}}
>
<Checkbox color="primary" checked={isSelectedRow} />
</TableCell>}
{header.map((headCell) => (
<TableCell
key={headCell.id.toString()}
sx={{
minWidth: headCell.width,
maxWidth: headCell.width,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
backgroundColor: 'white',
...(headCell.sticky
? {
position: 'sticky',
zIndex: 1,
...headCell.sticky,
}
: {}),
}}
>
{item[headCell.id as keyof TData] ?? '-'}
</TableCell>
))}
</TableRow>
</React.Fragment>
)
})}
</MuiTableBody>
</Table>
</TableContainer>
)
}) as <TData extends TDataBase>(
props: TableBodyProps<TData> & { ref?: React.Ref<HTMLDivElement> }
) => JSX.Element
※ forwardRef での Generics の使用について
ここでは React18 を使用しているため、スクロール機能の連携のために forwardRef を使用していますが、 forwardRef では Generics コンポーネントが作成できないため、今回はキャストで解決しています。
しかし、React19 では props から ref へアクセスできるようになっているので、このような型キャスト地獄とは無縁のようです。
使用例
export const TABLE_HEADERS: HeaderItem<Data>[] = [
{
id: 'id',
label: 'ID',
width: '80px',
sticky: {
left: '48px',
},
},
{
id: 'col1',
label: 'Col1',
width: '200px',
},
{
id: 'col2',
label: 'Col2',
width: '200px',
},
{
id: 'col3',
label: 'Col3',
width: '200px',
},
{
id: 'col4',
label: 'Col4',
width: '200px',
},
{
id: 'col5',
label: 'Col5',
width: '200px',
},
{
id: 'col6',
label: 'Col6',
width: '200px',
},
{
id: 'col7',
label: 'Col7',
width: '100px',
},
{
id: 'col8',
label: 'Col8',
width: '200px',
},
{
id: 'col9',
label: 'Col9',
width: '200px',
},
{
id: 'col10',
label: 'Col10',
width: '200px',
},
]
const [paginationParams, setPaginationParams] = React.useState({
page: 1,
perPage: 20,
})
const [order, setOrder] = React.useState<Order>('asc')
const [orderBy, setOrderBy] =
React.useState<keyof Data>('id')
const [selected, setSelected] = React.useState<Data[]>([])
// TanStack Query のケース
const { data, refetch, isLoading, isError } = useQuery({
queryFn: async () => {
return await axios.get<{
data: Data[]
total: number
}>(...)
},
})
return (
<Table
isLoading={isLoading}
header={TABLE_HEADERS}
data={data}
total={data.total}
selected={selected}
setSelected={setSelected}
paginationParams={paginationParams}
onPaginationChange={setPaginationParams}
order={order}
setOrder={setOrder}
orderBy={orderBy}
setOrderBy={setOrderBy}
/>
)