From f52bcd2415facf43264ac8bc0db13d35e312e5e5 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Sun, 16 Nov 2025 14:34:43 -0800 Subject: [PATCH] add sticky disc group rows for album detail --- .../hooks/use-sticky-table-group-rows.tsx | 177 +++++++++++++++ ...header.tsx => use-sticky-table-header.tsx} | 2 +- .../item-table-list.module.css | 27 +++ .../item-table-list/item-table-list.tsx | 211 +++++++++++++++++- .../components/album-detail-content.tsx | 13 +- 5 files changed, 416 insertions(+), 14 deletions(-) create mode 100644 src/renderer/components/item-list/item-table-list/hooks/use-sticky-table-group-rows.tsx rename src/renderer/components/item-list/item-table-list/hooks/{use-fixed-table-header.tsx => use-sticky-table-header.tsx} (96%) diff --git a/src/renderer/components/item-list/item-table-list/hooks/use-sticky-table-group-rows.tsx b/src/renderer/components/item-list/item-table-list/hooks/use-sticky-table-group-rows.tsx new file mode 100644 index 000000000..c11209c0e --- /dev/null +++ b/src/renderer/components/item-list/item-table-list/hooks/use-sticky-table-group-rows.tsx @@ -0,0 +1,177 @@ +import { useEffect, useMemo, useState } from 'react'; + +import { useWindowSettings } from '/@/renderer/store/settings.store'; +import { Platform } from '/@/shared/types/types'; + +export interface GroupRowInfo { + groupIndex: number; + rowIndex: number; +} + +export const useStickyTableGroupRows = ({ + containerRef, + enabled, + getGroupRowHeight, + getRowHeight, + groups, + headerHeight, + mainGridRef, + shouldShowStickyHeader, + stickyHeaderTop, +}: { + containerRef: React.RefObject; + enabled: boolean; + getGroupRowHeight?: (groupIndex: number) => number; + getRowHeight: (index: number) => number; + groups?: Array<{ itemCount: number; rowHeight?: ((index: number) => number) | number }>; + headerHeight: number; + mainGridRef: React.RefObject; + shouldShowStickyHeader?: boolean; + stickyHeaderTop?: number; +}) => { + const { windowBarStyle } = useWindowSettings(); + const [stickyGroupIndex, setStickyGroupIndex] = useState(null); + + const stickyTop = useMemo(() => { + // If sticky header is showing, position group row below it with 1px offset to avoid conflict + // Otherwise, use the base sticky position + if (shouldShowStickyHeader && stickyHeaderTop !== undefined) { + return stickyHeaderTop + headerHeight + 1; + } + return windowBarStyle === Platform.WINDOWS || windowBarStyle === Platform.MACOS ? 95 : 65; + }, [windowBarStyle, shouldShowStickyHeader, stickyHeaderTop, headerHeight]); + + // Calculate group row indexes + const groupRowIndexes = useMemo(() => { + if (!groups || groups.length === 0) { + return []; + } + + const indexes: GroupRowInfo[] = []; + let cumulativeDataIndex = 0; + const headerOffset = 1; // Assuming header is enabled + + groups.forEach((group, groupIndex) => { + const groupHeaderIndex = headerOffset + cumulativeDataIndex + groupIndex; + indexes.push({ + groupIndex, + rowIndex: groupHeaderIndex, + }); + cumulativeDataIndex += group.itemCount; + }); + + return indexes; + }, [groups]); + + useEffect(() => { + if ( + !enabled || + !groups || + groups.length === 0 || + !mainGridRef.current || + !containerRef.current + ) { + return; + } + + // Get the actual scrollable grid element (first child of the container) + const mainGridContainer = mainGridRef.current; + const mainGrid = mainGridContainer.childNodes[0] as HTMLDivElement | null; + + if (!mainGrid) { + return; + } + + const updateStickyGroup = () => { + const scrollTop = mainGrid.scrollTop || 0; + const containerRect = containerRef.current?.getBoundingClientRect(); + + if (!containerRect) { + return; + } + + // Calculate the sticky threshold position + // The sticky group row should appear when a group row scrolls past this position + // stickyTop already accounts for window bar style and sticky header offset + const containerTop = containerRect.top; + const baseStickyPosition = stickyTop; // Base position (window bar + sticky header if showing) + + // Find which group row should be sticky + // We want to show the current group as soon as its row reaches the sticky position + // This way it updates "on scroll" when scrolling into a new group section + let targetGroupIndex: null | number = null; + + // Iterate forward through groups to find which one is at or about to reach the sticky position + for (let i = 0; i < groupRowIndexes.length; i++) { + const { groupIndex, rowIndex } = groupRowIndexes[i]; + + // Calculate the top position of this group row relative to the grid scroll + let rowTop = headerHeight; + for (let r = 0; r < rowIndex; r++) { + rowTop += getRowHeight(r); + } + + // Calculate where this row would be in the viewport (absolute position from top of viewport) + const rowViewportTop = containerTop + rowTop - scrollTop; + + // Get the height of this group row to account for its own offset + const groupRowHeight = getGroupRowHeight ? getGroupRowHeight(groupIndex) : 40; // Default group row height + + // Calculate the sticky position accounting for the sticky group row's own height + // Similar to how stickyTop accounts for sticky header height, we add the group row height + const stickyPosition = baseStickyPosition + groupRowHeight; + + // Check if this group row has reached or is about to reach the sticky position + // The sticky group row appears at baseStickyPosition, but we check when the actual group row + // reaches baseStickyPosition + groupRowHeight to account for the sticky group row's own height + if (rowViewportTop <= stickyPosition) { + // This group has reached the sticky position, so show this group + targetGroupIndex = groupIndex; + // Don't break here - continue checking to see if a later group should replace it + } else { + // This group hasn't reached the sticky position yet + // If we already found a target group, keep it and stop + // Otherwise, no group should be sticky yet + if (targetGroupIndex !== null) { + break; + } + } + } + + setStickyGroupIndex((prev) => { + if (prev !== targetGroupIndex) { + return targetGroupIndex; + } + return prev; + }); + }; + + updateStickyGroup(); + + mainGrid.addEventListener('scroll', updateStickyGroup, { passive: true }); + window.addEventListener('scroll', updateStickyGroup, true); + window.addEventListener('resize', updateStickyGroup); + + return () => { + mainGrid.removeEventListener('scroll', updateStickyGroup); + window.removeEventListener('scroll', updateStickyGroup, true); + window.removeEventListener('resize', updateStickyGroup); + }; + }, [ + enabled, + groups, + groupRowIndexes, + mainGridRef, + containerRef, + getGroupRowHeight, + getRowHeight, + headerHeight, + stickyTop, + ]); + + return { + shouldShowStickyGroupRow: stickyGroupIndex !== null, + stickyGroupIndex, + stickyTop, + }; +}; diff --git a/src/renderer/components/item-list/item-table-list/hooks/use-fixed-table-header.tsx b/src/renderer/components/item-list/item-table-list/hooks/use-sticky-table-header.tsx similarity index 96% rename from src/renderer/components/item-list/item-table-list/hooks/use-fixed-table-header.tsx rename to src/renderer/components/item-list/item-table-list/hooks/use-sticky-table-header.tsx index 37404ea4d..33ee7c70f 100644 --- a/src/renderer/components/item-list/item-table-list/hooks/use-fixed-table-header.tsx +++ b/src/renderer/components/item-list/item-table-list/hooks/use-sticky-table-header.tsx @@ -4,7 +4,7 @@ import { RefObject, useMemo } from 'react'; import { useWindowSettings } from '/@/renderer/store/settings.store'; import { Platform } from '/@/shared/types/types'; -export const useFixedTableHeader = ({ +export const useStickyTableHeader = ({ containerRef, enabled, headerRef, diff --git a/src/renderer/components/item-list/item-table-list/item-table-list.module.css b/src/renderer/components/item-list/item-table-list/item-table-list.module.css index 6fe350cf2..084681418 100644 --- a/src/renderer/components/item-list/item-table-list/item-table-list.module.css +++ b/src/renderer/components/item-list/item-table-list/item-table-list.module.css @@ -88,6 +88,33 @@ pointer-events: auto; } +.sticky-group-row { + position: fixed; + z-index: 15; + display: flex; + flex-direction: row; + padding: 0 2rem; + margin: 0 -2rem; + pointer-events: none; + background-color: var(--theme-colors-background); + border-bottom: 1px solid var(--theme-colors-border); +} + +.sticky-group-row-content { + display: flex; + flex-direction: row; + width: 100%; + background-color: var(--theme-colors-background); +} + +.sticky-group-row-section { + display: flex; + flex-direction: row; + overflow: hidden; + pointer-events: auto; + background-color: var(--theme-colors-background); +} + .item-table-pinned-rows-grid-container.with-header::after { position: absolute; right: 0; diff --git a/src/renderer/components/item-list/item-table-list/item-table-list.tsx b/src/renderer/components/item-list/item-table-list/item-table-list.tsx index 61bc1014d..0058d5a1a 100644 --- a/src/renderer/components/item-list/item-table-list/item-table-list.tsx +++ b/src/renderer/components/item-list/item-table-list/item-table-list.tsx @@ -19,7 +19,6 @@ import React, { } from 'react'; import { type CellComponentProps, Grid } from 'react-window-v2'; -import { useFixedTableHeader } from './hooks/use-fixed-table-header'; import styles from './item-table-list.module.css'; import { ExpandedListContainer } from '/@/renderer/components/item-list/expanded-list-container'; @@ -32,6 +31,8 @@ import { useItemListState, } from '/@/renderer/components/item-list/helpers/item-list-state'; import { parseTableColumns } from '/@/renderer/components/item-list/helpers/parse-table-columns'; +import { useStickyTableGroupRows } from '/@/renderer/components/item-list/item-table-list/hooks/use-sticky-table-group-rows'; +import { useStickyTableHeader } from '/@/renderer/components/item-list/item-table-list/hooks/use-sticky-table-header'; import { ItemControls, ItemListHandle, @@ -620,6 +621,7 @@ interface ItemTableListProps { enableHorizontalBorders?: boolean; enableRowHoverHighlight?: boolean; enableSelection?: boolean; + enableStickyGroupRows?: boolean; enableStickyHeader?: boolean; enableVerticalBorders?: boolean; getRowId?: ((item: unknown) => string) | string; @@ -658,6 +660,7 @@ export const ItemTableList = ({ enableHorizontalBorders = false, enableRowHoverHighlight = true, enableSelection = true, + enableStickyGroupRows = false, enableStickyHeader = false, enableVerticalBorders = false, getRowId, @@ -784,13 +787,14 @@ export const ItemTableList = ({ const handleRef = useRef(null); const containerFocusRef = useRef(null); - const { shouldShowStickyHeader, stickyTop } = useFixedTableHeader({ + const { shouldShowStickyHeader, stickyTop } = useStickyTableHeader({ containerRef: containerFocusRef, enabled: enableHeader && enableStickyHeader, headerRef: pinnedRowRef, }); const stickyHeaderRef = useRef(null); + const stickyGroupRowRef = useRef(null); // Sync scroll position and update position of sticky header useEffect(() => { @@ -1429,9 +1433,84 @@ export const ItemTableList = ({ [enableHeader, headerHeight, rowHeight, pinnedRowCount, size, groups], ); + // Create a wrapper for getRowHeight that doesn't require cellProps (for sticky group rows hook) + const getRowHeightWrapper = useCallback( + (index: number) => { + const height = size === 'compact' ? 40 : size === 'large' ? 88 : 64; + + // Check if this row is a group header row and has a custom row height + if (groups && groups.length > 0) { + let cumulativeDataIndex = 0; + const headerOffset = enableHeader ? 1 : 0; + + for (let groupIndex = 0; groupIndex < groups.length; groupIndex++) { + const group = groups[groupIndex]; + const groupHeaderIndex = headerOffset + cumulativeDataIndex + groupIndex; + + if (index === groupHeaderIndex) { + if (group.rowHeight !== undefined) { + const groupRowHeight = + typeof group.rowHeight === 'number' + ? group.rowHeight + : group.rowHeight(index); + if (groupRowHeight !== undefined) { + return groupRowHeight; + } + } + break; + } + + cumulativeDataIndex += group.itemCount; + } + } + + const baseHeight = typeof rowHeight === 'number' ? rowHeight : height; + + // If enableHeader is true and this is the first sticky row, use fixed header height + if (enableHeader && index === 0 && pinnedRowCount > 0) { + return headerHeight; + } + + return baseHeight; + }, + [enableHeader, headerHeight, rowHeight, pinnedRowCount, size, groups], + ); + + const getGroupRowHeightWrapper = useCallback( + (groupIndex: number) => { + if (!groups || groupIndex < 0 || groupIndex >= groups.length) { + return 40; + } + + const group = groups[groupIndex]; + if (group.rowHeight !== undefined) { + return typeof group.rowHeight === 'number' ? group.rowHeight : group.rowHeight(0); + } + return 40; + }, + [groups], + ); + + const { + shouldShowStickyGroupRow, + stickyGroupIndex, + stickyTop: stickyGroupTop, + } = useStickyTableGroupRows({ + containerRef: containerFocusRef, + enabled: enableStickyGroupRows && !!groups && groups.length > 0, + getGroupRowHeight: getGroupRowHeightWrapper, + getRowHeight: getRowHeightWrapper, + groups, + headerHeight, + mainGridRef: rowRef, + shouldShowStickyHeader, + stickyHeaderTop: stickyTop, + }); + + // Show sticky group row whenever it should be shown + const shouldRenderStickyGroupRow = shouldShowStickyGroupRow; + const getDataFn = useCallback(() => { - // Reconstruct data array with group headers inserted - // Groups are defined by itemCount, so we calculate indexes based on cumulative item counts const result: (null | unknown)[] = enableHeader ? [null] : []; if (!groups || groups.length === 0) { @@ -1446,7 +1525,6 @@ export const ItemTableList = ({ const headerOffset = enableHeader ? 1 : 0; groups.forEach((group, groupIndex) => { - // Group header appears before its items // Index = header offset + cumulative data index + number of previous group headers const groupHeaderIndex = headerOffset + cumulativeDataIndex + groupIndex; groupIndexes.push(groupHeaderIndex); @@ -1457,16 +1535,14 @@ export const ItemTableList = ({ const startIndex = enableHeader ? 1 : 0; let groupHeaderCount = 0; - // Iterate through the expanded row space (data + group headers) for ( let rowIndex = startIndex; rowIndex < startIndex + data.length + groupIndexes.length; rowIndex++ ) { - // Check if this row should have a group header const expectedGroupIndex = groupIndexes[groupHeaderCount]; if (expectedGroupIndex !== undefined && rowIndex === expectedGroupIndex) { - result.push(null); // Group header row + result.push(null); groupHeaderCount++; } else if (dataIndex < data.length) { result.push(data[dataIndex]); @@ -1841,6 +1917,124 @@ export const ItemTableList = ({ stickyHeaderItemProps, ]); + // Calculate group row height + const groupRowHeight = useMemo(() => { + if (stickyGroupIndex === null || !groups) { + return 40; // Default + } + + const group = groups[stickyGroupIndex]; + if (group.rowHeight !== undefined) { + return typeof group.rowHeight === 'number' ? group.rowHeight : group.rowHeight(0); + } + return 40; // Default group row height + }, [stickyGroupIndex, groups]); + + const StickyGroupRow = useMemo(() => { + if (!shouldRenderStickyGroupRow || stickyGroupIndex === null || !groups) { + return null; + } + + const group = groups[stickyGroupIndex]; + const originalData = data.filter((item) => item !== null); + let cumulativeDataIndex = 0; + for (let i = 0; i < stickyGroupIndex; i++) { + cumulativeDataIndex += groups[i].itemCount; + } + + const groupContent = group.render({ + data: originalData, + groupIndex: stickyGroupIndex, + index: 0, + internalState, + startDataIndex: cumulativeDataIndex, + }); + + const pinnedLeftWidth = calculatedColumnWidths + .slice(0, pinnedLeftColumnCount) + .reduce((sum, width) => sum + width, 0); + const mainWidth = calculatedColumnWidths + .slice(pinnedLeftColumnCount, pinnedLeftColumnCount + totalColumnCount) + .reduce((sum, width) => sum + width, 0); + const pinnedRightWidth = calculatedColumnWidths + .slice(pinnedLeftColumnCount + totalColumnCount) + .reduce((sum, width) => sum + width, 0); + + const totalTableWidth = calculatedColumnWidths.reduce((sum, width) => sum + width, 0); + + // Calculate the actual sticky position accounting for sticky header + const actualStickyTop = stickyGroupTop; + + return ( +
+
+ {pinnedLeftColumnCount > 0 && ( +
+
+ {groupContent} +
+
+ )} +
+
0 ? `-${pinnedLeftWidth}px` : 0, + width: `${totalTableWidth}px`, + }} + > + {groupContent} +
+
+ {pinnedRightColumnCount > 0 && ( +
+
+ {groupContent} +
+
+ )} +
+
+ ); + }, [ + shouldRenderStickyGroupRow, + stickyGroupIndex, + groups, + data, + internalState, + calculatedColumnWidths, + pinnedLeftColumnCount, + pinnedRightColumnCount, + totalColumnCount, + groupRowHeight, + stickyGroupTop, + ]); + return (
{StickyHeader} + {StickyGroupRow} { + {t('common.disc', { postProcess: 'sentenceCase' })}{' '} + {discGroup.discNumber} + {discGroup.discSubtitle && ` - ${discGroup.discSubtitle}`} + + } onChange={handleCheckboxChange} size="xs" /> - - {t('common.disc', { postProcess: 'sentenceCase' })}{' '} - {discGroup.discNumber} - {discGroup.discSubtitle && ` - ${discGroup.discSubtitle}`} - ); }, @@ -437,6 +439,7 @@ const AlbumDetailSongsTable = ({ songs }: AlbumDetailSongsTableProps) => { enableHorizontalBorders={tableConfig.enableHorizontalBorders} enableRowHoverHighlight={tableConfig.enableRowHoverHighlight} enableSelection + enableStickyGroupRows enableStickyHeader enableVerticalBorders={tableConfig.enableVerticalBorders} groups={groups}