Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[@mantine/core] Fix: ScrollContainer sticky header #7004

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions packages/@mantine/core/src/components/Table/StickyTableHeader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
export class StickyTableHeader {
private table: HTMLTableElement;
private clone: HTMLDivElement;
private top: { max: number };
private headerHeight: number = 0;
private tableRect: DOMRect | null = null;
private scrollContainer: HTMLElement | null = null;
private tableContainer: HTMLElement | null = null;

constructor(
table: HTMLTableElement,
clone: HTMLDivElement,
top: { max: number },
tableContainer: HTMLElement
) {
this.table = table;
this.clone = clone;
this.top = top;
this.tableContainer = tableContainer;
this.scrollContainer = this.findScrollContainer(table);
this.init();
}

private init() {
this.cloneHeader();
this.bindEvents();
this.updatePosition();
}

private findScrollContainer(element: HTMLElement): HTMLElement | null {
let parent = element.parentElement;
while (parent) {
const overflowX = window.getComputedStyle(parent).overflowX;
if (overflowX === 'auto' || overflowX === 'scroll') {
return parent;
}
parent = parent.parentElement;
}
return null;
}

private cloneHeader() {
const originalHeader = this.table.querySelector('thead');
if (originalHeader) {
this.clone.innerHTML = '';
const clonedTable = this.table.cloneNode(false) as HTMLTableElement;
const clonedHeader = originalHeader.cloneNode(true) as HTMLTableSectionElement;
clonedTable.appendChild(clonedHeader);
this.clone.appendChild(clonedTable);
this.headerHeight = originalHeader.offsetHeight;

// Copy styles from original table to cloned table
const styles = window.getComputedStyle(this.table);
Object.assign(clonedTable.style, {
borderCollapse: styles.borderCollapse,
borderSpacing: styles.borderSpacing,
width: styles.width,
tableLayout: 'fixed',
});

this.updateColumnWidths();
}
}

private updateColumnWidths() {
const originalHeader = this.table.querySelector('thead');
const clonedTable = this.clone.querySelector('table');
if (originalHeader && clonedTable) {
const originalCells = originalHeader.querySelectorAll('th, td');
const clonedCells = clonedTable.querySelectorAll('th, td');
originalCells.forEach((cell, index) => {
const width = (cell as HTMLElement).offsetWidth;
(clonedCells[index] as HTMLElement).style.width = `${width}px`;
(clonedCells[index] as HTMLElement).style.minWidth = `${width}px`;
(clonedCells[index] as HTMLElement).style.maxWidth = `${width}px`;
});
clonedTable.style.width = `${this.table.offsetWidth}px`;
}
}

private bindEvents() {
window.addEventListener('scroll', this.updatePosition);
window.addEventListener('resize', this.updatePosition);
if (this.scrollContainer) {
this.scrollContainer.addEventListener('scroll', this.updatePosition);
}
}

public updatePosition = () => {
if (!this.tableContainer) return;
const containerRect = this.tableContainer.getBoundingClientRect();
const tableRect = this.table.getBoundingClientRect();

if (
containerRect.top - this.top.max <= 0 &&
containerRect.bottom - this.headerHeight - this.top.max >= 0
) {
this.clone.style.display = 'block';
this.clone.style.position = 'absolute';
this.clone.style.top = `${Math.max(0, this.top.max - containerRect.top)}px`;
this.clone.style.left = `${tableRect.left - containerRect.left}px`;
this.clone.style.width = `${tableRect.width}px`;

// Adjust horizontal scroll
if (this.scrollContainer) {
const scrollLeft = this.scrollContainer.scrollLeft;
const clonedTable = this.clone.querySelector('table') as HTMLTableElement;
if (clonedTable) {
clonedTable.style.marginLeft = `-${scrollLeft}px`;
}
}

this.updateColumnWidths(); // Update column widths on each position update
} else {
this.clone.style.display = 'none';
}

this.tableRect = tableRect;
};

public destroy() {
window.removeEventListener('scroll', this.updatePosition);
window.removeEventListener('resize', this.updatePosition);
if (this.scrollContainer) {
this.scrollContainer.removeEventListener('scroll', this.updatePosition);
}
}
}
101 changes: 99 additions & 2 deletions packages/@mantine/core/src/components/Table/Table.module.css
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
.table {
width: 100%;
border-collapse: collapse;
border-spacing: 0;
font-family: var(--mantine-font-family);
line-height: var(--mantine-line-height);
font-size: var(--mantine-font-size-sm);
table-layout: var(--table-layout, auto);
Expand All @@ -22,10 +24,30 @@
&:where([data-with-table-border]) {
border: rem(1px) solid var(--table-border-color);
}

&[data-sticky-header] {
position: relative;
}

&[data-sticky-header] thead {
position: sticky;
top: var(--table-sticky-header-offset, 0);
z-index: 2;
background-color: var(--mantine-color-body);
}

&[data-sticky-header] thead th {
position: sticky;
top: var(--table-sticky-header-offset, 0);
z-index: 2;
background-color: var(--mantine-color-body);
}
}

.th {
text-align: left;
font-weight: bold;
padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);

@mixin where-rtl {
text-align: right;
Expand Down Expand Up @@ -79,10 +101,10 @@
.thead {
top: var(--table-sticky-header-offset, 0);
z-index: 3;
background-color: var(--mantine-color-body);

&:where([data-sticky]) {
position: sticky;
background-color: var(--mantine-color-body);
}
}

Expand All @@ -99,9 +121,84 @@
}

.scrollContainer {
overflow-x: var(--table-overflow);
width: 100%;
overflow-x: auto;
overflow-y: visible; /* Changed from hidden to visible */
}

.scrollContainerInner {
min-width: var(--table-min-width);
}

.stickyHeader {
display: none;
position: fixed;
top: 0;
z-index: 1000;
background-color: var(--mantine-color-body);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
overflow: hidden;
}

.scrollContainerInner[data-sticky-header='true'] thead th {
position: sticky;
top: var(--table-sticky-header-top, 0);
z-index: 2;
background-color: var(--mantine-color-body);
}

.scrollContainerInner[data-sticky-left-column='true'] td:first-child,
.scrollContainerInner[data-sticky-left-column='true'] th:first-child {
position: sticky;
left: 0;
z-index: 1;
background-color: var(--mantine-color-body);
}

.scrollContainerInner[data-sticky-header='true'][data-sticky-left-column='true']
thead
th:first-child {
z-index: 3;
}

.stickyHeader[data-visible='true'] {
display: block;
}

.stickyHeader table {
width: 100%;
}

.stickyHeader th {
background-color: var(--mantine-color-body);
}

.withTableBorder {
border: 1px solid var(--mantine-color-gray-3);
}

.withColumnBorders td,
.withColumnBorders th {
border-right: 1px solid var(--mantine-color-gray-3);
}

.withColumnBorders td:last-child,
.withColumnBorders th:last-child {
border-right: none;
}

.withRowBorders tr {
border-bottom: 1px solid var(--mantine-color-gray-3);
}

.withRowBorders tr:last-child {
border-bottom: none;
}

.striped tr:nth-child(even) {
background-color: var(--mantine-color-gray-0);
}

.highlightOnHover tr:hover {
background-color: var(--mantine-color-gray-1);
}
Loading