From 48dcd3ef39983baddfe52bda0dbd6e8b33c51b01 Mon Sep 17 00:00:00 2001 From: Nicolas Javkin Date: Mon, 14 Oct 2024 15:00:07 -0300 Subject: [PATCH 1/3] added height to scrollContainer --- packages/@mantine/core/src/components/Table/Table.module.css | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/@mantine/core/src/components/Table/Table.module.css b/packages/@mantine/core/src/components/Table/Table.module.css index e8c9704766b..688da203267 100644 --- a/packages/@mantine/core/src/components/Table/Table.module.css +++ b/packages/@mantine/core/src/components/Table/Table.module.css @@ -100,6 +100,7 @@ .scrollContainer { overflow-x: var(--table-overflow); + height: 95vh; } .scrollContainerInner { From bca493dc29dce7824bcd2628a81b6754b9f40279 Mon Sep 17 00:00:00 2001 From: nicojav Date: Mon, 21 Oct 2024 09:38:13 -0300 Subject: [PATCH 2/3] Added sticky functionality to TableScrollContainer --- .../src/components/Table/StickyTableHeader.ts | 128 ++++++++++++++++ .../src/components/Table/Table.module.css | 106 +++++++++++++- .../components/Table/TableScrollContainer.tsx | 137 +++++++++++++----- 3 files changed, 327 insertions(+), 44 deletions(-) create mode 100644 packages/@mantine/core/src/components/Table/StickyTableHeader.ts diff --git a/packages/@mantine/core/src/components/Table/StickyTableHeader.ts b/packages/@mantine/core/src/components/Table/StickyTableHeader.ts new file mode 100644 index 00000000000..201a8795a73 --- /dev/null +++ b/packages/@mantine/core/src/components/Table/StickyTableHeader.ts @@ -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); + } + } +} diff --git a/packages/@mantine/core/src/components/Table/Table.module.css b/packages/@mantine/core/src/components/Table/Table.module.css index 688da203267..9fa3ef0335b 100644 --- a/packages/@mantine/core/src/components/Table/Table.module.css +++ b/packages/@mantine/core/src/components/Table/Table.module.css @@ -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); @@ -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; @@ -79,11 +101,7 @@ .thead { top: var(--table-sticky-header-offset, 0); z-index: 3; - - &:where([data-sticky]) { - position: sticky; - background-color: var(--mantine-color-body); - } + background-color: var(--mantine-color-body); } .caption { @@ -99,10 +117,84 @@ } .scrollContainer { - overflow-x: var(--table-overflow); - height: 95vh; + 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); +} diff --git a/packages/@mantine/core/src/components/Table/TableScrollContainer.tsx b/packages/@mantine/core/src/components/Table/TableScrollContainer.tsx index e2e6e7bf7c1..d3226c2f813 100644 --- a/packages/@mantine/core/src/components/Table/TableScrollContainer.tsx +++ b/packages/@mantine/core/src/components/Table/TableScrollContainer.tsx @@ -1,3 +1,4 @@ +import React, { useEffect, useRef } from 'react'; import { Box, BoxProps, @@ -10,10 +11,15 @@ import { useProps, useStyles, } from '../../core'; -import { ScrollArea } from '../ScrollArea'; +import { ScrollArea, ScrollAreaProps } from '../ScrollArea'; +import { StickyTableHeader } from './StickyTableHeader'; import classes from './Table.module.css'; -export type TableScrollContainerStylesNames = 'scrollContainer' | 'scrollContainerInner'; +export type TableScrollContainerStylesNames = + | 'scrollContainer' + | 'scrollContainerInner' + | 'stickyHeader'; + export type TableScrollContainerCssVariables = { scrollContainer: '--table-min-width' | '--table-overflow'; }; @@ -22,11 +28,10 @@ export interface TableScrollContainerProps extends BoxProps, StylesApiProps, ElementProps<'div'> { - /** `min-width` of the `Table` at which it should become scrollable */ - minWidth: React.CSSProperties['minWidth']; - - /** Type of the scroll container, `native` to use native scrollbars, `scrollarea` to use `ScrollArea` component, `scrollarea` by default */ + minWidth?: React.CSSProperties['minWidth']; type?: 'native' | 'scrollarea'; + stickyHeader?: boolean; + stickyHeaderOffset?: number; } export type TableScrollContainerFactory = Factory<{ @@ -38,54 +43,112 @@ export type TableScrollContainerFactory = Factory<{ const defaultProps: Partial = { type: 'scrollarea', + stickyHeader: false, + stickyHeaderOffset: 0, }; -const varsResolver = createVarsResolver((_, { minWidth, type }) => ({ +const varsResolver = createVarsResolver((_, { minWidth }) => ({ scrollContainer: { - '--table-min-width': rem(minWidth), - '--table-overflow': type === 'native' ? 'auto' : undefined, + '--table-min-width': minWidth ? rem(minWidth) : undefined, + '--table-overflow': 'hidden', }, })); -export const TableScrollContainer = factory((_props, ref) => { - const props = useProps('TableScrollContainer', defaultProps, _props); - const { - classNames, - className, - style, - styles, - unstyled, - vars, - children, - minWidth, - type, - ...others - } = props; +export const TableScrollContainer = factory((props, ref) => { + const { className, children, minWidth, type, stickyHeader, stickyHeaderOffset, ...others } = + useProps('TableScrollContainer', defaultProps, props); const getStyles = useStyles({ name: 'TableScrollContainer', classes, props, className, - style, - classNames, - styles, - unstyled, - vars, + style: others.style, + classNames: others.classNames, + styles: others.styles, + unstyled: others.unstyled, + vars: others.vars, varsResolver, rootSelector: 'scrollContainer', }); + const tableRef = useRef(null); + const containerRef = useRef(null); + const cloneContainerRef = useRef(null); + const stickyInstance = useRef(null); + + useEffect(() => { + if (stickyHeader && tableRef.current && cloneContainerRef.current && containerRef.current) { + stickyInstance.current = new StickyTableHeader( + tableRef.current, + cloneContainerRef.current, + { + max: stickyHeaderOffset ?? 0, + }, + containerRef.current + ); + + return () => { + if (stickyInstance.current) { + stickyInstance.current.destroy(); + } + }; + } + }, [stickyHeader, stickyHeaderOffset, children]); + + const content = ( +
+
+ {React.Children.map(children, (child) => { + if (React.isValidElement(child) && typeof child.type !== 'string') { + return React.cloneElement( + child as React.ReactElement<{ ref: React.Ref }>, + { ref: tableRef } + ); + } + return child; + })} +
+ {stickyHeader && ( +
+ )} +
+ ); + + if (type === 'native') { + return ( + + {content} + + ); + } + return ( - - component={type === 'scrollarea' ? ScrollArea : 'div'} - {...(type === 'scrollarea' ? { offsetScrollbars: 'x' } : {})} - ref={ref} - {...getStyles('scrollContainer')} - {...others} - > -
{children}
- + + {content} + ); }); From bd6135210445dc42b7b6528ef614ac7c652b5758 Mon Sep 17 00:00:00 2001 From: nicojav Date: Mon, 21 Oct 2024 09:49:51 -0300 Subject: [PATCH 3/3] fixed original table head sticky --- packages/@mantine/core/src/components/Table/Table.module.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/@mantine/core/src/components/Table/Table.module.css b/packages/@mantine/core/src/components/Table/Table.module.css index 9fa3ef0335b..52eb544851d 100644 --- a/packages/@mantine/core/src/components/Table/Table.module.css +++ b/packages/@mantine/core/src/components/Table/Table.module.css @@ -102,6 +102,10 @@ top: var(--table-sticky-header-offset, 0); z-index: 3; background-color: var(--mantine-color-body); + + &:where([data-sticky]) { + position: sticky; + } } .caption {