Skip to content

Commit

Permalink
Make sticky table header clickable (#495)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
annavik authored Aug 7, 2024
1 parent 4d26ad0 commit aa15c54
Show file tree
Hide file tree
Showing 5 changed files with 313 additions and 12 deletions.
1 change: 0 additions & 1 deletion ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@
"react-plotly.js": "^2.6.0",
"react-router-dom": "^6.8.2",
"typescript": "^4.4.2",
"vh-sticky-table-header": "^1.7.0",
"vite": "^4.5.3",
"vite-tsconfig-paths": "^4.2.1"
},
Expand Down
3 changes: 2 additions & 1 deletion ui/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import styles from './app.module.scss'

const queryClient = new QueryClient()

const APP_CONTAINER_ID = 'app'
const INTRO_CONTAINER_ID = 'intro'

export const App = () => {
Expand All @@ -43,7 +44,7 @@ export const App = () => {
<UserInfoContextProvider>
<BreadcrumbContextProvider>
<ReactQueryDevtools initialIsOpen={false} />
<div className={styles.wrapper}>
<div id={APP_CONTAINER_ID} className={styles.wrapper}>
<div id={INTRO_CONTAINER_ID}>
<Header />
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ReactNode, RefObject, useLayoutEffect, useRef } from 'react'
import { StickyTableHeader } from 'vh-sticky-table-header'
import styles from './table.module.scss'
import StickyTableHeader from './vh-sticky-table-header'

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

// Destory the sticky header once the main table is unmounted.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,308 @@
/**
* This class is used for both sticky header and horizontal scroll support on tables.
* The class has been modified to fully support click handling until https://github.com/archfz/vh-sticky-table-header/issues/10 is fixed.
*
* Original code: https://github.com/archfz/vh-sticky-table-header/blob/main/src/StickyTableHeader.ts
*/
export default class StickyTableHeader {
private sizeListener?: EventListener
private scrollListener?: EventListener
private currentFrameRequest?: number
private containerScrollListener?: EventListener
private clickListener?: (event: MouseEvent) => any
private tableContainerParent: HTMLDivElement
private tableContainer: HTMLTableElement
private cloneContainer: HTMLTableElement
private cloneContainerParent: HTMLDivElement
private cloneHeader: any = null
private scrollParents: HTMLElement[]
private header: HTMLTableRowElement
private lastElement: HTMLElement | null = null
private lastElementRefresh: NodeJS.Timeout | number | null = null
private top: { max: number | string; [key: number]: number | string }
private scrollContainer?: HTMLElement

constructor(
tableContainer: HTMLTableElement,
cloneContainer: HTMLTableElement,
top?: { max: number | string; [key: number]: number | string },
scrollContainer?: HTMLElement
) {
const header = tableContainer.querySelector<HTMLTableRowElement>('thead')
this.tableContainer = tableContainer
this.cloneContainer = cloneContainer
this.top = top || { max: 0 }
this.scrollContainer = scrollContainer

if (!header || !this.tableContainer.parentNode) {
throw new Error(
'Header or parent node of sticky header table container not found!'
)
}

this.tableContainerParent = this.tableContainer.parentNode as HTMLDivElement
this.cloneContainerParent = this.cloneContainer.parentNode as HTMLDivElement
this.header = header
this.scrollParents = this.getScrollParents(this.tableContainer)

this.setup()
}

private getScrollParents(node: HTMLElement): HTMLElement[] {
const parents: HTMLElement[] = []
let parent: any = node.parentNode

while (parent) {
if (parent.scrollHeight > parent.clientHeight && parent !== window) {
parents.push(parent)
}
parent = parent.parentNode as HTMLElement | null
}

return parents
}

public destroy(): void {
if (this.scrollListener) {
window.removeEventListener('scroll', this.scrollListener)
this.scrollParents.forEach((parent) => {
parent.removeEventListener('scroll', this.scrollListener!)
})
}
if (this.currentFrameRequest) {
window.cancelAnimationFrame(this.currentFrameRequest)
}
if (this.sizeListener) {
window.removeEventListener('resize', this.sizeListener)
}
if (this.containerScrollListener) {
this.tableContainerParent.removeEventListener(
'click',
this.containerScrollListener
)
}
if (this.clickListener) {
this.cloneContainer.removeEventListener('click', this.clickListener)
}
if (this.cloneHeader) {
this.cloneContainer.removeChild(this.cloneHeader)
}
}

private setupClickEventMirroring(): void {
this.clickListener = (event: MouseEvent) => {
let containerRect = this.tableContainer.getBoundingClientRect()
const cloneRect = this.cloneContainer.getBoundingClientRect()
const bodyRect = document.body.getBoundingClientRect()
const scrollElement = this.scrollContainer ?? window
const currentScroll = this.scrollContainer
? this.scrollContainer.scrollTop
: window.scrollY
scrollElement.scrollTo({
top: containerRect.y - bodyRect.y - this.getTop() - 60,
})

containerRect = this.tableContainer.getBoundingClientRect()
const originalTarget = document.elementFromPoint(
containerRect.x + (event.clientX - cloneRect.x),
containerRect.y + (event.clientY - cloneRect.y)
)
if (originalTarget && (originalTarget as HTMLElement).click) {
const _element = originalTarget as HTMLElement
_element.click()
}
scrollElement.scrollTo({ top: currentScroll })
}
this.cloneContainer.addEventListener('click', this.clickListener)
}

private setupSticky(): void {
if (this.cloneContainerParent.parentNode) {
const _element = this.cloneContainerParent.parentNode as HTMLElement
_element.style.position = 'relative'
}

const updateSticky = () => {
this.currentFrameRequest = window.requestAnimationFrame(() => {
const tableRect = this.tableContainer.getBoundingClientRect()
const tableOffsetTop = this.tableContainer.offsetTop
const tableTop = tableRect.y
const tableBottom = this.getBottom()

const diffTop = -tableTop
const diffBottom = -tableBottom
const topPx = this.getTop()

if (diffTop > -topPx && this.cloneHeader === null) {
this.cloneContainerParent.style.display = 'none'
this.cloneHeader = this.createClone()
}

if (this.cloneHeader !== null) {
if (diffTop <= -topPx) {
this.cloneContainerParent.style.display = 'none'
this.cloneContainer.removeChild(this.cloneHeader)
this.cloneHeader = null
} else if (diffBottom < -topPx) {
this.cloneContainerParent.style.display = 'block'
this.cloneContainerParent.style.position = 'fixed'
this.cloneContainerParent.style.top = `${topPx}px`
this.setHorizontalScrollOnClone()
} else {
this.cloneContainerParent.style.display = 'block'
this.cloneContainerParent.style.position = 'absolute'
this.cloneContainerParent.style.top = `${
tableBottom - tableTop + tableOffsetTop
}px`
}
}
})
}
this.scrollListener = () => updateSticky()
updateSticky()

window.addEventListener('scroll', this.scrollListener)
this.scrollParents.forEach((parent) => {
parent.addEventListener('scroll', this.scrollListener!)
})
}

private setup(): void {
this.setupSticky()
this.setupSizeMirroring()
this.setupClickEventMirroring()
this.setupHorizontalScrollMirroring()
}

private setupSizeMirroring(): void {
this.sizeListener = () => {
window.requestAnimationFrame(() => {
const headerSize = this.header.getBoundingClientRect().width
this.cloneContainer.style.width = `${headerSize}px`
this.cloneContainerParent.style.top = `${this.getTop()}px`
this.setHorizontalScrollOnClone()
})
}
window.addEventListener('resize', this.sizeListener)
}

private setupHorizontalScrollMirroring(): void {
this.containerScrollListener = () => {
window.requestAnimationFrame(() => {
this.setHorizontalScrollOnClone()
})
}
this.tableContainerParent.addEventListener(
'scroll',
this.containerScrollListener
)
}

private createClone(): HTMLTableRowElement {
const clone = this.header.cloneNode(true) as HTMLTableRowElement
this.cloneContainer.append(clone)

const headerSize = this.header.getBoundingClientRect().width

Array.from(this.header.children).forEach((row, rowIndex) => {
Array.from(row.children).forEach((cell, index) => {
const _element = clone.children[rowIndex].children[
index
] as HTMLTableCellElement
_element.style.width =
(cell.getBoundingClientRect().width / headerSize) * 100 + '%'
})
})

this.cloneContainer.style.display = 'table'
this.cloneContainer.style.width = `${headerSize}px`

this.cloneContainerParent.style.position = 'fixed'
this.cloneContainerParent.style.overflow = 'hidden'
this.cloneContainerParent.style.top = `${this.getTop()}px`

this.setHorizontalScrollOnClone()

return clone
}

private setHorizontalScrollOnClone(): void {
this.cloneContainerParent.style.width = `${
this.tableContainerParent.getBoundingClientRect().width
}px`
this.cloneContainerParent.scrollLeft = this.tableContainerParent.scrollLeft
}

private sizeToPx(size: number | string): number {
if (typeof size === 'number') {
return size
} else if (size.match(/rem$/)) {
const rem = +size.replace(/rem$/, '')
return (
Number.parseFloat(
window.getComputedStyle(document.getElementsByTagName('html')[0])
.fontSize
) * rem
)
} else {
// eslint-disable-next-line no-console
console.error(
'Unsupported size format for sticky table header displacement.'
)
return 0
}
}

private getTop(): number {
const windowWidth = document.body.getBoundingClientRect().width
const sizes = Object.entries(this.top)
.filter(([key]) => key !== 'max')
.sort(
([key1], [key2]) =>
Number.parseInt(key1, 10) - Number.parseInt(key2, 10)
)

for (let i = 0, size; (size = sizes[i++]); ) {
if (windowWidth < Number.parseInt(size[0], 10)) {
return this.sizeToPx(size[1])
}
}

const top = this.sizeToPx(this.top.max)
const parentTops = this.scrollParents.map(
(c) => c.getBoundingClientRect().top
)

return Math.max(top, ...parentTops)
}

private getBottom(): number {
const tableRect = this.tableContainer.getBoundingClientRect()
const lastElement = this.getLastElement()
const headerHeight = this.header.getBoundingClientRect().height

const defaultBottom =
(lastElement
? lastElement.getBoundingClientRect().y
: tableRect.y + tableRect.height) - headerHeight
const parentBottoms = this.scrollParents.map(
(c) => c.getBoundingClientRect().bottom - 2 * headerHeight
)
return Math.min(defaultBottom, ...parentBottoms, Number.MAX_VALUE)
}

private getLastElement() {
if (!this.lastElement) {
this.lastElement = this.tableContainer.querySelector(
':scope > tbody > tr:last-child'
)
return this.lastElement
}

if (this.lastElementRefresh) {
clearTimeout(this.lastElementRefresh)
}
this.lastElementRefresh = setTimeout(() => this.lastElement, 2000)
return this.lastElement
}
}
8 changes: 0 additions & 8 deletions ui/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -6864,7 +6864,6 @@ __metadata:
storybook: "npm:^7.5.3"
ts-jest: "npm:^29.1.1"
typescript: "npm:^4.4.2"
vh-sticky-table-header: "npm:^1.7.0"
vite: "npm:^4.5.3"
vite-plugin-eslint: "npm:^1.8.1"
vite-plugin-svgr: "npm:^4.1.0"
Expand Down Expand Up @@ -16164,13 +16163,6 @@ __metadata:
languageName: node
linkType: hard

"vh-sticky-table-header@npm:^1.7.0":
version: 1.7.0
resolution: "vh-sticky-table-header@npm:1.7.0"
checksum: 00676702e1d3ab89a97ea6af5e0598a65b6742e74ef5793d0c1189ad492c7fb86199abe3986a848ba94a3eb7a05f1606a373c8decffb7e3f7d4a7c41cbfe9756
languageName: node
linkType: hard

"vite-plugin-eslint@npm:^1.8.1":
version: 1.8.1
resolution: "vite-plugin-eslint@npm:1.8.1"
Expand Down

0 comments on commit aa15c54

Please sign in to comment.