position – CSS: カスケーディングスタイルシート | MDN#sticky
端の一列は簡単です。td 要素にposition: stickyをつけ、固定したい方向にleft: 0の様にするのみでよしなにしてくれます。次のデモがこれです。
/* 1列目を左端に固定 */
td:first-child {
position: sticky;// 固定
left: 0; // 左端
}
上下の複数行はちょっと工夫が要りますが、シンプルではあります。上部はtheadで固定したい範囲を囲ってtheadにposition: stickyをかけます。下部はtfootとして同様です。次のデモがこれです。
thead {
position: sticky;
top: 0;
}
tfoot {
position: sticky;
bottom: 0;
}
左右の複数列は上下複数行に比べてずいぶん手間です。画面を描画した時、画面が変わった時、それぞれで都度左から何pxに置くべきかを計算して、それを要素に与える必要があります。あんまりにもあんまりなのでその内 colgroup に position:stickey をかけたらそれだけで複数列固定になるやもしれません。
これを実装したデモとソースコードが次です。
ソースコードを展開
// App.tsx
import "./styles.css";
import { AppTableCell } from "./AppTableCell";
export default function App() {
// 表のデータを作成
const arr = Array(50)
.fill(null)
.map((v, i) => {
return Array(50)
.fill(null)
.map((v, j) => `${i},${j}`);
});
return (
<div className="App">
<table>
<caption>左複数列を固定</caption>
<tbody>
{arr.map((vp, i) => (
<tr key={i}>
{/* sticky にしたい列だけ特別なコンポーネントを呼出し */}
{vp.map((vc, j) =>
j === 0 || j === 1 ? (
<AppTableCell key={`${i},${j}`} stickyIndex={j}>
{vc}
</AppTableCell>
) : (
<td key={`${i},${j}`}>{vc}</td>
)
)}
</tr>
))}
</tbody>
</table>
</div>
);
}
// AppTableCell.tsx
import React, { CSSProperties, useEffect, useRef } from "react";
import { atom, useAtom } from "jotai";
type AppTableCellProps = {
stickyIndex?: number; // sticky として左からいくつ目か(いくつ目と書いたが 0 スタートのインデックス的採番)
};
// 表中の各列の幅を管理する atom を用意。Cellコンポーネントの外でグローバルに管理できればなんでも良し
const tableCellWidths = atom<number[]>([]);
export const AppTableCell: React.FC<AppTableCellProps> = (props) => {
const { stickyIndex } = props;
const [tcw, setTcw] = useAtom(tableCellWidths);
// マスを直に触るので ref を用意
// @see https://ja.reactjs.org/docs/refs-and-the-dom.html
const cellRef = useRef<HTMLTableCellElement>(null);
// style.left
const [left, setLeft] = React.useState("");
// マスが変わった時に管理されている幅を更新
useEffect(() => {
if (typeof stickyIndex !== "number" || !cellRef.current) {
return;
}
// 更新ロジック
const updateAction = () => {
const tcwUpdater = (old: number[]) => {
if (typeof stickyIndex !== "number" || !cellRef.current) {
return old;
}
// 以前の幅と現在の幅が違うのであれば更新
if (
old[stickyIndex] !== cellRef.current.getBoundingClientRect().width
) {
old[stickyIndex] = cellRef.current.getBoundingClientRect().width;
}
return old;
};
setTcw(tcwUpdater);
// 左から何 px 目で固定されるべきなのかをグローバルに管理している各列の幅から計算する
setLeft(
`${tcwUpdater(tcw)
.filter((v, i) => i < stickyIndex)
.reduce((carry, cur) => carry + cur, 0)}px`
);
};
updateAction();
const observer = new ResizeObserver(updateAction);
observer.observe(cellRef.current);
return () => {
observer.disconnect();
};
}, [cellRef, tcw, setTcw, stickyIndex]);
const style: CSSProperties = {};
if (typeof stickyIndex === "number") {
// 左から何 px 目で固定されるべきなのかをグローバルに管理している各列の幅から計算する
// 計算内容は自分の左のマスまでで使われている幅の合計の算出
style.left = left;
style.background = "rgb(200, 200, 200)";
}
// 計算結果を含んだ style を受け渡し
return (
<td ref={cellRef} style={style}>
{props.children}
</td>
);
};
余談ですがこのコードは left の値を決定するコードにすぎず、固定対象の列の幅が固定されているのであれば、デモの様に都度計算する必要はなく、単にtd:first-child {left:0px;},td:nth-child(2) {left:1列目の幅px;},td:nth-child(3) {left:(1列目の幅+2列目の幅)px;},…と固定幅に合わせてべた書きするだけで同じものを作れます。