Skip to content

Commit b29271f

Browse files
authored
feat: support sticky table headers (#706)
1 parent b1b37aa commit b29271f

4 files changed

Lines changed: 355 additions & 7 deletions

File tree

src/components/MDXComponents/ExpandableTable.tsx

Lines changed: 263 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,84 @@
11
import * as React from "react";
2+
import clsx from "clsx";
23
import {
34
CloseLargeIcon,
45
ExpandCornersIcon,
56
} from "components/MDXComponents/ExpandIcons";
67

8+
type TableChildProps = {
9+
children?: React.ReactNode;
10+
mdxType?: string;
11+
originalType?: string;
12+
};
13+
14+
type StickyHeaderMetrics = {
15+
tableWidth: number;
16+
headerHeight: number;
17+
columnWidths: number[];
18+
};
19+
20+
type TablePropsWithRef = React.TableHTMLAttributes<HTMLTableElement> & {
21+
ref?: React.Ref<HTMLTableElement>;
22+
};
23+
24+
const StickyHeaderTableContext = React.createContext(false);
25+
26+
const findTableHead = (children: React.ReactNode): React.ReactNode => {
27+
for (const child of React.Children.toArray(children)) {
28+
if (!React.isValidElement<TableChildProps>(child)) continue;
29+
30+
if (
31+
child.type === "thead" ||
32+
child.props.mdxType === "thead" ||
33+
child.props.originalType === "thead"
34+
) {
35+
return child;
36+
}
37+
38+
if (child.type === React.Fragment) {
39+
const tableHead = findTableHead(child.props.children);
40+
if (tableHead) return tableHead;
41+
}
42+
}
43+
44+
return null;
45+
};
46+
47+
export function StickyHeaderTable({
48+
children,
49+
}: {
50+
children?: React.ReactNode;
51+
}) {
52+
return (
53+
<StickyHeaderTableContext.Provider value={true}>
54+
{children}
55+
</StickyHeaderTableContext.Provider>
56+
);
57+
}
58+
759
export function ExpandableTable(
860
props: React.TableHTMLAttributes<HTMLTableElement>
961
) {
62+
const { ref: _ref, ...tableProps } = props as TablePropsWithRef;
1063
const [open, setOpen] = React.useState(false);
64+
const [stickyHeaderMetrics, setStickyHeaderMetrics] =
65+
React.useState<StickyHeaderMetrics | null>(null);
66+
const [stickyHeaderActive, setStickyHeaderActive] = React.useState(false);
67+
const stickyHeaderScrollRef = React.useRef<HTMLDivElement | null>(null);
68+
const tableRef = React.useRef<HTMLTableElement | null>(null);
69+
const stickyHeaderOptIn = React.useContext(StickyHeaderTableContext);
70+
const hasStickyHeaderClass =
71+
tableProps.className?.split(/\s+/).includes("sticky-header") ?? false;
72+
const isStickyHeader = stickyHeaderOptIn || hasStickyHeaderClass;
73+
const stickyTableProps = {
74+
...tableProps,
75+
className: clsx(
76+
tableProps.className,
77+
isStickyHeader && !hasStickyHeaderClass && "sticky-header"
78+
),
79+
};
80+
const tableHead = findTableHead(tableProps.children);
81+
const hasStickyHeaderClone = Boolean(stickyHeaderMetrics && tableHead);
1182

1283
React.useEffect(() => {
1384
if (!open) return;
@@ -29,6 +100,181 @@ export function ExpandableTable(
29100
};
30101
}, [open]);
31102

103+
React.useEffect(() => {
104+
if (!isStickyHeader) return;
105+
106+
const syncStickyHeader = () => {
107+
if (!stickyHeaderScrollRef.current || !tableRef.current) return;
108+
stickyHeaderScrollRef.current.scrollLeft = tableRef.current.scrollLeft;
109+
};
110+
111+
const tableScroll = tableRef.current;
112+
tableScroll?.addEventListener("scroll", syncStickyHeader, {
113+
passive: true,
114+
});
115+
syncStickyHeader();
116+
117+
return () => {
118+
tableScroll?.removeEventListener("scroll", syncStickyHeader);
119+
};
120+
}, [isStickyHeader, stickyHeaderMetrics]);
121+
122+
React.useEffect(() => {
123+
if (!isStickyHeader || !hasStickyHeaderClone || !stickyHeaderMetrics) {
124+
setStickyHeaderActive(false);
125+
return;
126+
}
127+
128+
let rafId: number | null = null;
129+
const updateStickyHeaderActive = () => {
130+
rafId = null;
131+
const table = tableRef.current;
132+
const stickyHeader = stickyHeaderScrollRef.current;
133+
if (!table || !stickyHeader) {
134+
setStickyHeaderActive(false);
135+
return;
136+
}
137+
138+
const stickyTop = Number.parseFloat(
139+
window.getComputedStyle(stickyHeader).top
140+
);
141+
const stickyHeaderTop = Number.isFinite(stickyTop) ? stickyTop : 0;
142+
const tableRect = table.getBoundingClientRect();
143+
const nextActive =
144+
tableRect.top <= stickyHeaderTop &&
145+
tableRect.bottom > stickyHeaderTop + stickyHeaderMetrics.headerHeight;
146+
147+
if (nextActive) {
148+
stickyHeader.scrollLeft = table.scrollLeft;
149+
}
150+
setStickyHeaderActive((current) =>
151+
current === nextActive ? current : nextActive
152+
);
153+
};
154+
155+
const scheduleUpdate = () => {
156+
if (rafId != null) {
157+
window.cancelAnimationFrame(rafId);
158+
}
159+
rafId = window.requestAnimationFrame(updateStickyHeaderActive);
160+
};
161+
162+
scheduleUpdate();
163+
window.addEventListener("scroll", scheduleUpdate, { passive: true });
164+
window.addEventListener("resize", scheduleUpdate);
165+
166+
return () => {
167+
if (rafId != null) {
168+
window.cancelAnimationFrame(rafId);
169+
}
170+
window.removeEventListener("scroll", scheduleUpdate);
171+
window.removeEventListener("resize", scheduleUpdate);
172+
};
173+
}, [isStickyHeader, hasStickyHeaderClone, stickyHeaderMetrics]);
174+
175+
React.useEffect(() => {
176+
if (!isStickyHeader) return;
177+
178+
let rafId: number | null = null;
179+
const measure = () => {
180+
const table = tableRef.current;
181+
const tableHead = table?.tHead;
182+
const headerCells = tableHead?.querySelectorAll("th");
183+
if (!table || !tableHead || !headerCells || headerCells.length === 0) {
184+
return;
185+
}
186+
const tableWidth = Math.max(
187+
table.getBoundingClientRect().width,
188+
table.scrollWidth
189+
);
190+
const headerHeight = tableHead.getBoundingClientRect().height;
191+
if (tableWidth === 0 || headerHeight === 0) return;
192+
193+
const nextMetrics = {
194+
tableWidth,
195+
headerHeight,
196+
columnWidths: Array.from(headerCells).map(
197+
(cell) => cell.getBoundingClientRect().width
198+
),
199+
};
200+
201+
setStickyHeaderMetrics((current) => {
202+
if (
203+
current?.tableWidth === nextMetrics.tableWidth &&
204+
current.headerHeight === nextMetrics.headerHeight &&
205+
current.columnWidths.length === nextMetrics.columnWidths.length &&
206+
current.columnWidths.every(
207+
(width, index) => width === nextMetrics.columnWidths[index]
208+
)
209+
) {
210+
return current;
211+
}
212+
213+
return nextMetrics;
214+
});
215+
};
216+
217+
const scheduleMeasure = () => {
218+
if (rafId != null) {
219+
window.cancelAnimationFrame(rafId);
220+
}
221+
rafId = window.requestAnimationFrame(measure);
222+
};
223+
224+
scheduleMeasure();
225+
window.addEventListener("resize", scheduleMeasure);
226+
const resizeObserver =
227+
typeof ResizeObserver === "undefined"
228+
? null
229+
: new ResizeObserver(scheduleMeasure);
230+
if (tableRef.current && resizeObserver) {
231+
resizeObserver.observe(tableRef.current);
232+
}
233+
234+
return () => {
235+
if (rafId != null) {
236+
window.cancelAnimationFrame(rafId);
237+
}
238+
window.removeEventListener("resize", scheduleMeasure);
239+
resizeObserver?.disconnect();
240+
};
241+
}, [isStickyHeader]);
242+
243+
// The cloned header is visual-only and aria-hidden, so sticky table headers
244+
// should not contain controls.
245+
const stickyHeader =
246+
isStickyHeader && stickyHeaderMetrics && tableHead ? (
247+
<div
248+
className={clsx(
249+
"sticky-header-scroll",
250+
!stickyHeaderActive && "sticky-header-scroll-hidden"
251+
)}
252+
ref={stickyHeaderScrollRef}
253+
aria-hidden="true"
254+
style={{
255+
height: stickyHeaderMetrics.headerHeight,
256+
marginBottom: -stickyHeaderMetrics.headerHeight,
257+
}}
258+
>
259+
<table
260+
{...stickyTableProps}
261+
className={clsx(stickyTableProps.className, "sticky-header-clone")}
262+
style={{
263+
...stickyTableProps.style,
264+
width: stickyHeaderMetrics.tableWidth,
265+
}}
266+
>
267+
<colgroup>
268+
{stickyHeaderMetrics.columnWidths.map((width, index) => (
269+
<col key={index} style={{ width }} />
270+
))}
271+
</colgroup>
272+
{tableHead}
273+
</table>
274+
</div>
275+
) : null;
276+
const modalTableProps = isStickyHeader ? stickyTableProps : tableProps;
277+
32278
return (
33279
<div className="expandable-table">
34280
<div className="expandable-table-toolbar">
@@ -41,7 +287,22 @@ export function ExpandableTable(
41287
<ExpandCornersIcon />
42288
</button>
43289
</div>
44-
<table {...props} />
290+
{isStickyHeader ? (
291+
<>
292+
{stickyHeader}
293+
<table
294+
{...stickyTableProps}
295+
ref={tableRef}
296+
className={clsx(
297+
stickyTableProps.className,
298+
"sticky-header-source",
299+
stickyHeaderActive && "sticky-header-source-hidden"
300+
)}
301+
/>
302+
</>
303+
) : (
304+
<table {...props} />
305+
)}
45306
{open && (
46307
<div
47308
className="expandable-modal-backdrop"
@@ -63,7 +324,7 @@ export function ExpandableTable(
63324
<CloseLargeIcon />
64325
</button>
65326
<div className="expandable-modal-scroll">
66-
<table {...props} />
327+
<table {...modalTableProps} />
67328
</div>
68329
</div>
69330
</div>

src/components/MDXComponents/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,5 +40,5 @@ export {
4040
} from "components/MDXComponents/ResourceCard";
4141

4242
export { TargetLink as Link } from "./Link";
43-
export { ExpandableTable as table } from "./ExpandableTable";
43+
export { ExpandableTable as table, StickyHeaderTable } from "./ExpandableTable";
4444
export { ExpandableImage as img } from "./ExpandableImage";

0 commit comments

Comments
 (0)