support scroll sync on sticky table header and groups

This commit is contained in:
jeffvli
2025-11-20 14:40:09 -08:00
parent 948fc40b3e
commit c4f94495a8
4 changed files with 271 additions and 64 deletions
@@ -1,5 +1,5 @@
import { useInView } from 'motion/react';
import { RefObject, useMemo } from 'react';
import { RefObject, useEffect, useMemo, useRef } from 'react';
import { useWindowSettings } from '/@/renderer/store/settings.store';
import { Platform } from '/@/shared/types/types';
@@ -8,12 +8,26 @@ export const useStickyTableHeader = ({
containerRef,
enabled,
headerRef,
mainGridRef,
pinnedLeftColumnRef,
pinnedRightColumnRef,
stickyHeaderMainRef,
}: {
containerRef: RefObject<HTMLDivElement | null>;
enabled: boolean;
headerRef: RefObject<HTMLDivElement | null>;
mainGridRef?: RefObject<HTMLDivElement | null>;
pinnedLeftColumnRef?: RefObject<HTMLDivElement | null>;
pinnedRightColumnRef?: RefObject<HTMLDivElement | null>;
stickyHeaderMainRef?: RefObject<HTMLDivElement | null>;
}) => {
const { windowBarStyle } = useWindowSettings();
const isScrollingRef = useRef({
main: false,
pinnedLeft: false,
pinnedRight: false,
stickyHeader: false,
});
const topMargin =
windowBarStyle === Platform.WINDOWS || windowBarStyle === Platform.MACOS
@@ -36,6 +50,144 @@ export const useStickyTableHeader = ({
return windowBarStyle === Platform.WINDOWS || windowBarStyle === Platform.MACOS ? 95 : 65;
}, [windowBarStyle]);
// Sync scroll between sticky header and main grid/pinned columns
useEffect(() => {
if (!shouldShowStickyHeader || !stickyHeaderMainRef?.current || !mainGridRef?.current) {
return;
}
const stickyMainSection = stickyHeaderMainRef.current;
const mainGrid = mainGridRef.current.childNodes[0] as HTMLDivElement;
const pinnedLeft = pinnedLeftColumnRef?.current?.childNodes[0] as HTMLDivElement | null;
const pinnedRight = pinnedRightColumnRef?.current?.childNodes[0] as HTMLDivElement | null;
if (!mainGrid) {
return;
}
// Sync initial scroll position when sticky header becomes visible
const syncInitialScroll = () => {
const scrollLeft = mainGrid.scrollLeft;
const scrollTop = mainGrid.scrollTop;
// Sync horizontal scroll position
stickyMainSection.scrollTo({
behavior: 'instant',
left: scrollLeft,
});
// Sync vertical scroll position with pinned columns
if (pinnedLeft) {
pinnedLeft.scrollTo({
behavior: 'instant',
top: scrollTop,
});
}
if (pinnedRight) {
pinnedRight.scrollTo({
behavior: 'instant',
top: scrollTop,
});
}
};
// Sync initial position after a frame to ensure elements are ready
requestAnimationFrame(() => {
requestAnimationFrame(syncInitialScroll);
});
const syncScroll = (e: Event) => {
const target = e.currentTarget as HTMLDivElement;
const scrollLeft = target.scrollLeft;
const scrollTop = target.scrollTop;
// Sync horizontal scroll from main grid to sticky header main section
if (target === mainGrid && !isScrollingRef.current.stickyHeader) {
isScrollingRef.current.stickyHeader = true;
stickyMainSection.scrollTo({
behavior: 'instant',
left: scrollLeft,
});
isScrollingRef.current.stickyHeader = false;
}
// Sync horizontal scroll from sticky header to main grid
if (target === stickyMainSection && !isScrollingRef.current.main) {
isScrollingRef.current.main = true;
mainGrid.scrollTo({
behavior: 'instant',
left: scrollLeft,
});
isScrollingRef.current.main = false;
}
// Sync vertical scroll from main grid to pinned columns
if (target === mainGrid) {
if (pinnedLeft && !isScrollingRef.current.pinnedLeft) {
isScrollingRef.current.pinnedLeft = true;
pinnedLeft.scrollTo({
behavior: 'instant',
top: scrollTop,
});
isScrollingRef.current.pinnedLeft = false;
}
if (pinnedRight && !isScrollingRef.current.pinnedRight) {
isScrollingRef.current.pinnedRight = true;
pinnedRight.scrollTo({
behavior: 'instant',
top: scrollTop,
});
isScrollingRef.current.pinnedRight = false;
}
}
// Sync vertical scroll from pinned columns to main grid
if (pinnedLeft && target === pinnedLeft && !isScrollingRef.current.main) {
isScrollingRef.current.main = true;
mainGrid.scrollTo({
behavior: 'instant',
top: scrollTop,
});
isScrollingRef.current.main = false;
}
if (pinnedRight && target === pinnedRight && !isScrollingRef.current.main) {
isScrollingRef.current.main = true;
mainGrid.scrollTo({
behavior: 'instant',
top: scrollTop,
});
isScrollingRef.current.main = false;
}
};
mainGrid.addEventListener('scroll', syncScroll);
stickyMainSection.addEventListener('scroll', syncScroll);
if (pinnedLeft) {
pinnedLeft.addEventListener('scroll', syncScroll);
}
if (pinnedRight) {
pinnedRight.addEventListener('scroll', syncScroll);
}
return () => {
mainGrid.removeEventListener('scroll', syncScroll);
stickyMainSection.removeEventListener('scroll', syncScroll);
if (pinnedLeft) {
pinnedLeft.removeEventListener('scroll', syncScroll);
}
if (pinnedRight) {
pinnedRight.removeEventListener('scroll', syncScroll);
}
};
}, [
shouldShowStickyHeader,
mainGridRef,
pinnedLeftColumnRef,
pinnedRightColumnRef,
stickyHeaderMainRef,
]);
return {
shouldShowStickyHeader,
stickyTop,
@@ -67,12 +67,10 @@
z-index: 15;
display: flex;
flex-direction: row;
padding: 0 2rem;
margin: 0 -2rem;
overflow: hidden;
pointer-events: none;
background-color: var(--theme-colors-background);
border-bottom: 1px solid var(--theme-colors-border);
box-shadow: 0 -1px 0 0 var(--theme-colors-border);
}
.sticky-header-row {
@@ -93,8 +91,8 @@
z-index: 15;
display: flex;
flex-direction: row;
padding: 0 2rem;
margin: 0 -2rem;
padding: 0;
margin: 0;
pointer-events: none;
background-color: var(--theme-colors-background);
border-bottom: 1px solid var(--theme-colors-border);
@@ -104,7 +102,6 @@
display: flex;
flex-direction: row;
width: 100%;
background-color: var(--theme-colors-background);
}
.sticky-group-row-section {
@@ -112,7 +109,6 @@
flex-direction: row;
overflow: hidden;
pointer-events: auto;
background-color: var(--theme-colors-background);
}
.item-table-pinned-rows-grid-container.with-header::after {
@@ -845,57 +845,45 @@ export const ItemTableList = ({
const handleRef = useRef<ItemListHandle | null>(null);
const containerFocusRef = useRef<HTMLDivElement | null>(null);
const stickyHeaderRef = useRef<HTMLDivElement | null>(null);
const stickyGroupRowRef = useRef<HTMLDivElement | null>(null);
const stickyHeaderLeftRef = useRef<HTMLDivElement | null>(null);
const stickyHeaderMainRef = useRef<HTMLDivElement | null>(null);
const stickyHeaderRightRef = useRef<HTMLDivElement | null>(null);
const { shouldShowStickyHeader, stickyTop } = useStickyTableHeader({
containerRef: containerFocusRef,
enabled: enableHeader && enableStickyHeader,
headerRef: pinnedRowRef,
mainGridRef: rowRef,
pinnedLeftColumnRef,
pinnedRightColumnRef,
stickyHeaderMainRef,
});
const stickyHeaderRef = useRef<HTMLDivElement | null>(null);
const stickyGroupRowRef = useRef<HTMLDivElement | null>(null);
// Sync scroll position and update position of sticky header
// Update position and width of sticky header (scroll sync is handled in the hook)
useEffect(() => {
if (
!shouldShowStickyHeader ||
!stickyHeaderRef.current ||
!rowRef.current ||
!containerFocusRef.current
) {
if (!shouldShowStickyHeader || !stickyHeaderRef.current || !containerFocusRef.current) {
return;
}
const stickyHeader = stickyHeaderRef.current;
const stickyMainSection = stickyHeader.querySelector(
`.${styles.stickyHeaderSection}`,
) as HTMLDivElement;
const mainGrid = rowRef.current.childNodes[0] as HTMLDivElement;
const container = containerFocusRef.current;
if (!stickyMainSection || !mainGrid || !container) {
return;
}
const updatePosition = () => {
const containerRect = container.getBoundingClientRect();
stickyHeader.style.left = `${containerRect.left}px`;
};
const syncScroll = () => {
stickyMainSection.scrollLeft = mainGrid.scrollLeft;
stickyHeader.style.width = `${containerRect.width}px`;
};
updatePosition();
syncScroll();
window.addEventListener('resize', updatePosition);
window.addEventListener('scroll', updatePosition, true);
mainGrid.addEventListener('scroll', syncScroll);
return () => {
window.removeEventListener('resize', updatePosition);
window.removeEventListener('scroll', updatePosition, true);
mainGrid.removeEventListener('scroll', syncScroll);
};
}, [shouldShowStickyHeader]);
@@ -1516,6 +1504,36 @@ export const ItemTableList = ({
// Show sticky group row whenever it should be shown
const shouldRenderStickyGroupRow = shouldShowStickyGroupRow;
// Update position and width of sticky group row
useEffect(() => {
if (
!shouldRenderStickyGroupRow ||
!stickyGroupRowRef.current ||
!containerFocusRef.current
) {
return;
}
const stickyGroupRow = stickyGroupRowRef.current;
const container = containerFocusRef.current;
const updatePosition = () => {
const containerRect = container.getBoundingClientRect();
stickyGroupRow.style.left = `${containerRect.left}px`;
stickyGroupRow.style.width = `${containerRect.width}px`;
};
updatePosition();
window.addEventListener('resize', updatePosition);
window.addEventListener('scroll', updatePosition, true);
return () => {
window.removeEventListener('resize', updatePosition);
window.removeEventListener('scroll', updatePosition, true);
};
}, [shouldRenderStickyGroupRow]);
const getDataFn = useCallback(() => {
const result: (null | unknown)[] = enableHeader ? [null] : [];
@@ -1829,8 +1847,16 @@ export const ItemTableList = ({
<div className={styles.stickyHeaderRow}>
{pinnedLeftColumnCount > 0 && (
<div
className={styles.stickyHeaderSection}
style={{ width: `${pinnedLeftWidth}px` }}
className={clsx(
styles.stickyHeaderSection,
styles.stickyHeaderPinnedLeft,
)}
ref={stickyHeaderLeftRef}
style={{
flex: '0 1 auto',
minWidth: `${pinnedLeftWidth}px`,
overflow: 'hidden',
}}
>
{parsedColumns
.filter((col) => col.pinned === 'left')
@@ -1855,33 +1881,62 @@ export const ItemTableList = ({
})}
</div>
)}
<div className={styles.stickyHeaderSection} style={{ width: `${mainWidth}px` }}>
{parsedColumns
.filter((col) => col.pinned === null)
.map((col) => {
const columnIndex = parsedColumns.findIndex((c) => c === col);
return (
<CellComponent
ariaAttributes={{
'aria-colindex': columnIndex + 1,
role: 'gridcell',
}}
columnIndex={columnIndex}
key={col.id}
rowIndex={0}
style={{
height: headerHeight,
width: calculatedColumnWidths[columnIndex],
}}
{...stickyHeaderItemProps}
/>
);
})}
<div
className={clsx(
styles.stickyHeaderSection,
styles.stickyHeaderMain,
styles.noScrollbar,
)}
ref={stickyHeaderMainRef}
style={{
flex: '1 1 auto',
minWidth: 0,
overflowX: 'auto',
overflowY: 'hidden',
}}
>
<div
style={{
display: 'flex',
minWidth: `${mainWidth}px`,
}}
>
{parsedColumns
.filter((col) => col.pinned === null)
.map((col) => {
const columnIndex = parsedColumns.findIndex((c) => c === col);
return (
<CellComponent
ariaAttributes={{
'aria-colindex': columnIndex + 1,
role: 'gridcell',
}}
columnIndex={columnIndex}
key={col.id}
rowIndex={0}
style={{
flexShrink: 0,
height: headerHeight,
width: calculatedColumnWidths[columnIndex],
}}
{...stickyHeaderItemProps}
/>
);
})}
</div>
</div>
{pinnedRightColumnCount > 0 && (
<div
className={styles.stickyHeaderSection}
style={{ width: `${pinnedRightWidth}px` }}
className={clsx(
styles.stickyHeaderSection,
styles.stickyHeaderPinnedRight,
)}
ref={stickyHeaderRightRef}
style={{
flex: '0 1 auto',
minWidth: `${pinnedRightWidth}px`,
overflow: 'hidden',
}}
>
{parsedColumns
.filter((col) => col.pinned === 'right')
@@ -2003,7 +2058,13 @@ export const ItemTableList = ({
)}
<div
className={styles.stickyGroupRowSection}
style={{ width: `${mainWidth}px` }}
style={{
marginLeft: pinnedLeftColumnCount > 0 ? 0 : '-2rem',
marginRight: '-2rem',
paddingLeft: pinnedLeftColumnCount > 0 ? 0 : '2rem',
paddingRight: '2rem',
width: `${mainWidth}px`,
}}
>
<div
style={{
@@ -2025,9 +2086,7 @@ export const ItemTableList = ({
height: groupRowHeight,
width: `${pinnedRightWidth}px`,
}}
>
{groupContent}
</div>
/>
</div>
)}
</div>
@@ -552,7 +552,7 @@ const AlbumDetailSongsTable = ({ songs }: AlbumDetailSongsTableProps) => {
id={`disc-${discGroup.discNumber}`}
indeterminate={isSomeSelected}
label={
<Text component="label" size="sm">
<Text component="label" size="sm" truncate>
{t('common.disc', { postProcess: 'sentenceCase' })}{' '}
{discGroup.discNumber}
{discGroup.discSubtitle && ` - ${discGroup.discSubtitle}`}