11import * as React from "react" ;
2+ import clsx from "clsx" ;
23import {
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+
759export 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 >
0 commit comments