Skip to content

Commit aa15c54

Browse files
authored
Make sticky table header clickable (#495)
* Include sticky header source code * Add some context to code * Fix some editor warnings * Fix sticky header click behaviour by making it possible to specify scroll container * Remove dependency to vh-sticky-table-header lib
1 parent 4d26ad0 commit aa15c54

File tree

5 files changed

+313
-12
lines changed

5 files changed

+313
-12
lines changed

ui/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,6 @@
3737
"react-plotly.js": "^2.6.0",
3838
"react-router-dom": "^6.8.2",
3939
"typescript": "^4.4.2",
40-
"vh-sticky-table-header": "^1.7.0",
4140
"vite": "^4.5.3",
4241
"vite-tsconfig-paths": "^4.2.1"
4342
},

ui/src/app.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import styles from './app.module.scss'
3434

3535
const queryClient = new QueryClient()
3636

37+
const APP_CONTAINER_ID = 'app'
3738
const INTRO_CONTAINER_ID = 'intro'
3839

3940
export const App = () => {
@@ -43,7 +44,7 @@ export const App = () => {
4344
<UserInfoContextProvider>
4445
<BreadcrumbContextProvider>
4546
<ReactQueryDevtools initialIsOpen={false} />
46-
<div className={styles.wrapper}>
47+
<div id={APP_CONTAINER_ID} className={styles.wrapper}>
4748
<div id={INTRO_CONTAINER_ID}>
4849
<Header />
4950
</div>

ui/src/design-system/components/table/table/sticky-header-table.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { ReactNode, RefObject, useLayoutEffect, useRef } from 'react'
2-
import { StickyTableHeader } from 'vh-sticky-table-header'
32
import styles from './table.module.scss'
3+
import StickyTableHeader from './vh-sticky-table-header'
44

55
/**
66
* Help component to make it possible to combine sticky table header and horizontal scrolling.
@@ -26,7 +26,8 @@ export const StickyHeaderTable = ({
2626
const sticky = new StickyTableHeader(
2727
tableRef.current,
2828
tableCloneRef.current,
29-
{ max: 96 }
29+
{ max: 96 },
30+
document.getElementById('app') ?? undefined
3031
)
3132

3233
// Destory the sticky header once the main table is unmounted.
Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
1+
/**
2+
* This class is used for both sticky header and horizontal scroll support on tables.
3+
* The class has been modified to fully support click handling until https://github.com/archfz/vh-sticky-table-header/issues/10 is fixed.
4+
*
5+
* Original code: https://github.com/archfz/vh-sticky-table-header/blob/main/src/StickyTableHeader.ts
6+
*/
7+
export default class StickyTableHeader {
8+
private sizeListener?: EventListener
9+
private scrollListener?: EventListener
10+
private currentFrameRequest?: number
11+
private containerScrollListener?: EventListener
12+
private clickListener?: (event: MouseEvent) => any
13+
private tableContainerParent: HTMLDivElement
14+
private tableContainer: HTMLTableElement
15+
private cloneContainer: HTMLTableElement
16+
private cloneContainerParent: HTMLDivElement
17+
private cloneHeader: any = null
18+
private scrollParents: HTMLElement[]
19+
private header: HTMLTableRowElement
20+
private lastElement: HTMLElement | null = null
21+
private lastElementRefresh: NodeJS.Timeout | number | null = null
22+
private top: { max: number | string; [key: number]: number | string }
23+
private scrollContainer?: HTMLElement
24+
25+
constructor(
26+
tableContainer: HTMLTableElement,
27+
cloneContainer: HTMLTableElement,
28+
top?: { max: number | string; [key: number]: number | string },
29+
scrollContainer?: HTMLElement
30+
) {
31+
const header = tableContainer.querySelector<HTMLTableRowElement>('thead')
32+
this.tableContainer = tableContainer
33+
this.cloneContainer = cloneContainer
34+
this.top = top || { max: 0 }
35+
this.scrollContainer = scrollContainer
36+
37+
if (!header || !this.tableContainer.parentNode) {
38+
throw new Error(
39+
'Header or parent node of sticky header table container not found!'
40+
)
41+
}
42+
43+
this.tableContainerParent = this.tableContainer.parentNode as HTMLDivElement
44+
this.cloneContainerParent = this.cloneContainer.parentNode as HTMLDivElement
45+
this.header = header
46+
this.scrollParents = this.getScrollParents(this.tableContainer)
47+
48+
this.setup()
49+
}
50+
51+
private getScrollParents(node: HTMLElement): HTMLElement[] {
52+
const parents: HTMLElement[] = []
53+
let parent: any = node.parentNode
54+
55+
while (parent) {
56+
if (parent.scrollHeight > parent.clientHeight && parent !== window) {
57+
parents.push(parent)
58+
}
59+
parent = parent.parentNode as HTMLElement | null
60+
}
61+
62+
return parents
63+
}
64+
65+
public destroy(): void {
66+
if (this.scrollListener) {
67+
window.removeEventListener('scroll', this.scrollListener)
68+
this.scrollParents.forEach((parent) => {
69+
parent.removeEventListener('scroll', this.scrollListener!)
70+
})
71+
}
72+
if (this.currentFrameRequest) {
73+
window.cancelAnimationFrame(this.currentFrameRequest)
74+
}
75+
if (this.sizeListener) {
76+
window.removeEventListener('resize', this.sizeListener)
77+
}
78+
if (this.containerScrollListener) {
79+
this.tableContainerParent.removeEventListener(
80+
'click',
81+
this.containerScrollListener
82+
)
83+
}
84+
if (this.clickListener) {
85+
this.cloneContainer.removeEventListener('click', this.clickListener)
86+
}
87+
if (this.cloneHeader) {
88+
this.cloneContainer.removeChild(this.cloneHeader)
89+
}
90+
}
91+
92+
private setupClickEventMirroring(): void {
93+
this.clickListener = (event: MouseEvent) => {
94+
let containerRect = this.tableContainer.getBoundingClientRect()
95+
const cloneRect = this.cloneContainer.getBoundingClientRect()
96+
const bodyRect = document.body.getBoundingClientRect()
97+
const scrollElement = this.scrollContainer ?? window
98+
const currentScroll = this.scrollContainer
99+
? this.scrollContainer.scrollTop
100+
: window.scrollY
101+
scrollElement.scrollTo({
102+
top: containerRect.y - bodyRect.y - this.getTop() - 60,
103+
})
104+
105+
containerRect = this.tableContainer.getBoundingClientRect()
106+
const originalTarget = document.elementFromPoint(
107+
containerRect.x + (event.clientX - cloneRect.x),
108+
containerRect.y + (event.clientY - cloneRect.y)
109+
)
110+
if (originalTarget && (originalTarget as HTMLElement).click) {
111+
const _element = originalTarget as HTMLElement
112+
_element.click()
113+
}
114+
scrollElement.scrollTo({ top: currentScroll })
115+
}
116+
this.cloneContainer.addEventListener('click', this.clickListener)
117+
}
118+
119+
private setupSticky(): void {
120+
if (this.cloneContainerParent.parentNode) {
121+
const _element = this.cloneContainerParent.parentNode as HTMLElement
122+
_element.style.position = 'relative'
123+
}
124+
125+
const updateSticky = () => {
126+
this.currentFrameRequest = window.requestAnimationFrame(() => {
127+
const tableRect = this.tableContainer.getBoundingClientRect()
128+
const tableOffsetTop = this.tableContainer.offsetTop
129+
const tableTop = tableRect.y
130+
const tableBottom = this.getBottom()
131+
132+
const diffTop = -tableTop
133+
const diffBottom = -tableBottom
134+
const topPx = this.getTop()
135+
136+
if (diffTop > -topPx && this.cloneHeader === null) {
137+
this.cloneContainerParent.style.display = 'none'
138+
this.cloneHeader = this.createClone()
139+
}
140+
141+
if (this.cloneHeader !== null) {
142+
if (diffTop <= -topPx) {
143+
this.cloneContainerParent.style.display = 'none'
144+
this.cloneContainer.removeChild(this.cloneHeader)
145+
this.cloneHeader = null
146+
} else if (diffBottom < -topPx) {
147+
this.cloneContainerParent.style.display = 'block'
148+
this.cloneContainerParent.style.position = 'fixed'
149+
this.cloneContainerParent.style.top = `${topPx}px`
150+
this.setHorizontalScrollOnClone()
151+
} else {
152+
this.cloneContainerParent.style.display = 'block'
153+
this.cloneContainerParent.style.position = 'absolute'
154+
this.cloneContainerParent.style.top = `${
155+
tableBottom - tableTop + tableOffsetTop
156+
}px`
157+
}
158+
}
159+
})
160+
}
161+
this.scrollListener = () => updateSticky()
162+
updateSticky()
163+
164+
window.addEventListener('scroll', this.scrollListener)
165+
this.scrollParents.forEach((parent) => {
166+
parent.addEventListener('scroll', this.scrollListener!)
167+
})
168+
}
169+
170+
private setup(): void {
171+
this.setupSticky()
172+
this.setupSizeMirroring()
173+
this.setupClickEventMirroring()
174+
this.setupHorizontalScrollMirroring()
175+
}
176+
177+
private setupSizeMirroring(): void {
178+
this.sizeListener = () => {
179+
window.requestAnimationFrame(() => {
180+
const headerSize = this.header.getBoundingClientRect().width
181+
this.cloneContainer.style.width = `${headerSize}px`
182+
this.cloneContainerParent.style.top = `${this.getTop()}px`
183+
this.setHorizontalScrollOnClone()
184+
})
185+
}
186+
window.addEventListener('resize', this.sizeListener)
187+
}
188+
189+
private setupHorizontalScrollMirroring(): void {
190+
this.containerScrollListener = () => {
191+
window.requestAnimationFrame(() => {
192+
this.setHorizontalScrollOnClone()
193+
})
194+
}
195+
this.tableContainerParent.addEventListener(
196+
'scroll',
197+
this.containerScrollListener
198+
)
199+
}
200+
201+
private createClone(): HTMLTableRowElement {
202+
const clone = this.header.cloneNode(true) as HTMLTableRowElement
203+
this.cloneContainer.append(clone)
204+
205+
const headerSize = this.header.getBoundingClientRect().width
206+
207+
Array.from(this.header.children).forEach((row, rowIndex) => {
208+
Array.from(row.children).forEach((cell, index) => {
209+
const _element = clone.children[rowIndex].children[
210+
index
211+
] as HTMLTableCellElement
212+
_element.style.width =
213+
(cell.getBoundingClientRect().width / headerSize) * 100 + '%'
214+
})
215+
})
216+
217+
this.cloneContainer.style.display = 'table'
218+
this.cloneContainer.style.width = `${headerSize}px`
219+
220+
this.cloneContainerParent.style.position = 'fixed'
221+
this.cloneContainerParent.style.overflow = 'hidden'
222+
this.cloneContainerParent.style.top = `${this.getTop()}px`
223+
224+
this.setHorizontalScrollOnClone()
225+
226+
return clone
227+
}
228+
229+
private setHorizontalScrollOnClone(): void {
230+
this.cloneContainerParent.style.width = `${
231+
this.tableContainerParent.getBoundingClientRect().width
232+
}px`
233+
this.cloneContainerParent.scrollLeft = this.tableContainerParent.scrollLeft
234+
}
235+
236+
private sizeToPx(size: number | string): number {
237+
if (typeof size === 'number') {
238+
return size
239+
} else if (size.match(/rem$/)) {
240+
const rem = +size.replace(/rem$/, '')
241+
return (
242+
Number.parseFloat(
243+
window.getComputedStyle(document.getElementsByTagName('html')[0])
244+
.fontSize
245+
) * rem
246+
)
247+
} else {
248+
// eslint-disable-next-line no-console
249+
console.error(
250+
'Unsupported size format for sticky table header displacement.'
251+
)
252+
return 0
253+
}
254+
}
255+
256+
private getTop(): number {
257+
const windowWidth = document.body.getBoundingClientRect().width
258+
const sizes = Object.entries(this.top)
259+
.filter(([key]) => key !== 'max')
260+
.sort(
261+
([key1], [key2]) =>
262+
Number.parseInt(key1, 10) - Number.parseInt(key2, 10)
263+
)
264+
265+
for (let i = 0, size; (size = sizes[i++]); ) {
266+
if (windowWidth < Number.parseInt(size[0], 10)) {
267+
return this.sizeToPx(size[1])
268+
}
269+
}
270+
271+
const top = this.sizeToPx(this.top.max)
272+
const parentTops = this.scrollParents.map(
273+
(c) => c.getBoundingClientRect().top
274+
)
275+
276+
return Math.max(top, ...parentTops)
277+
}
278+
279+
private getBottom(): number {
280+
const tableRect = this.tableContainer.getBoundingClientRect()
281+
const lastElement = this.getLastElement()
282+
const headerHeight = this.header.getBoundingClientRect().height
283+
284+
const defaultBottom =
285+
(lastElement
286+
? lastElement.getBoundingClientRect().y
287+
: tableRect.y + tableRect.height) - headerHeight
288+
const parentBottoms = this.scrollParents.map(
289+
(c) => c.getBoundingClientRect().bottom - 2 * headerHeight
290+
)
291+
return Math.min(defaultBottom, ...parentBottoms, Number.MAX_VALUE)
292+
}
293+
294+
private getLastElement() {
295+
if (!this.lastElement) {
296+
this.lastElement = this.tableContainer.querySelector(
297+
':scope > tbody > tr:last-child'
298+
)
299+
return this.lastElement
300+
}
301+
302+
if (this.lastElementRefresh) {
303+
clearTimeout(this.lastElementRefresh)
304+
}
305+
this.lastElementRefresh = setTimeout(() => this.lastElement, 2000)
306+
return this.lastElement
307+
}
308+
}

ui/yarn.lock

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6864,7 +6864,6 @@ __metadata:
68646864
storybook: "npm:^7.5.3"
68656865
ts-jest: "npm:^29.1.1"
68666866
typescript: "npm:^4.4.2"
6867-
vh-sticky-table-header: "npm:^1.7.0"
68686867
vite: "npm:^4.5.3"
68696868
vite-plugin-eslint: "npm:^1.8.1"
68706869
vite-plugin-svgr: "npm:^4.1.0"
@@ -16164,13 +16163,6 @@ __metadata:
1616416163
languageName: node
1616516164
linkType: hard
1616616165

16167-
"vh-sticky-table-header@npm:^1.7.0":
16168-
version: 1.7.0
16169-
resolution: "vh-sticky-table-header@npm:1.7.0"
16170-
checksum: 00676702e1d3ab89a97ea6af5e0598a65b6742e74ef5793d0c1189ad492c7fb86199abe3986a848ba94a3eb7a05f1606a373c8decffb7e3f7d4a7c41cbfe9756
16171-
languageName: node
16172-
linkType: hard
16173-
1617416166
"vite-plugin-eslint@npm:^1.8.1":
1617516167
version: 1.8.1
1617616168
resolution: "vite-plugin-eslint@npm:1.8.1"

0 commit comments

Comments
 (0)