-
Notifications
You must be signed in to change notification settings - Fork 3
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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
- Loading branch information
Showing
5 changed files
with
313 additions
and
12 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
308 changes: 308 additions & 0 deletions
308
ui/src/design-system/components/table/table/vh-sticky-table-header/index.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters