add sticky disc group rows for album detail

This commit is contained in:
jeffvli
2025-11-16 14:34:43 -08:00
parent f366b50550
commit f52bcd2415
5 changed files with 416 additions and 14 deletions
@@ -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<HTMLDivElement | null>;
enabled: boolean;
getGroupRowHeight?: (groupIndex: number) => number;
getRowHeight: (index: number) => number;
groups?: Array<{ itemCount: number; rowHeight?: ((index: number) => number) | number }>;
headerHeight: number;
mainGridRef: React.RefObject<HTMLDivElement | null>;
shouldShowStickyHeader?: boolean;
stickyHeaderTop?: number;
}) => {
const { windowBarStyle } = useWindowSettings();
const [stickyGroupIndex, setStickyGroupIndex] = useState<null | number>(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,
};
};
@@ -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,
@@ -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;
@@ -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<ItemListHandle | null>(null);
const containerFocusRef = useRef<HTMLDivElement | null>(null);
const { shouldShowStickyHeader, stickyTop } = useFixedTableHeader({
const { shouldShowStickyHeader, stickyTop } = useStickyTableHeader({
containerRef: containerFocusRef,
enabled: enableHeader && enableStickyHeader,
headerRef: pinnedRowRef,
});
const stickyHeaderRef = useRef<HTMLDivElement | null>(null);
const stickyGroupRowRef = useRef<HTMLDivElement | null>(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 (
<div
className={styles.stickyGroupRow}
ref={stickyGroupRowRef}
style={{
top: `${actualStickyTop}px`,
}}
>
<div className={styles.stickyGroupRowContent}>
{pinnedLeftColumnCount > 0 && (
<div
className={styles.stickyGroupRowSection}
style={{ width: `${pinnedLeftWidth}px` }}
>
<div
style={{
height: groupRowHeight,
width: `${pinnedLeftWidth}px`,
}}
>
{groupContent}
</div>
</div>
)}
<div
className={styles.stickyGroupRowSection}
style={{ width: `${mainWidth}px` }}
>
<div
style={{
height: groupRowHeight,
marginLeft: pinnedLeftWidth > 0 ? `-${pinnedLeftWidth}px` : 0,
width: `${totalTableWidth}px`,
}}
>
{groupContent}
</div>
</div>
{pinnedRightColumnCount > 0 && (
<div
className={styles.stickyGroupRowSection}
style={{ width: `${pinnedRightWidth}px` }}
>
<div
style={{
height: groupRowHeight,
width: `${pinnedRightWidth}px`,
}}
>
{groupContent}
</div>
</div>
)}
</div>
</div>
);
}, [
shouldRenderStickyGroupRow,
stickyGroupIndex,
groups,
data,
internalState,
calculatedColumnWidths,
pinnedLeftColumnCount,
pinnedRightColumnCount,
totalColumnCount,
groupRowHeight,
stickyGroupTop,
]);
return (
<div
className={styles.itemTableListContainer}
@@ -1856,6 +2050,7 @@ export const ItemTableList = ({
tabIndex={0}
>
{StickyHeader}
{StickyGroupRow}
<VirtualizedTableGrid
calculatedColumnWidths={calculatedColumnWidths}
CellComponent={CellComponent}
@@ -405,14 +405,16 @@ const AlbumDetailSongsTable = ({ songs }: AlbumDetailSongsTableProps) => {
<Checkbox
checked={isAllSelected}
indeterminate={isSomeSelected}
label={
<Text size="sm">
{t('common.disc', { postProcess: 'sentenceCase' })}{' '}
{discGroup.discNumber}
{discGroup.discSubtitle && ` - ${discGroup.discSubtitle}`}
</Text>
}
onChange={handleCheckboxChange}
size="xs"
/>
<Text size="sm">
{t('common.disc', { postProcess: 'sentenceCase' })}{' '}
{discGroup.discNumber}
{discGroup.discSubtitle && ` - ${discGroup.discSubtitle}`}
</Text>
</Group>
);
},
@@ -437,6 +439,7 @@ const AlbumDetailSongsTable = ({ songs }: AlbumDetailSongsTableProps) => {
enableHorizontalBorders={tableConfig.enableHorizontalBorders}
enableRowHoverHighlight={tableConfig.enableRowHoverHighlight}
enableSelection
enableStickyGroupRows
enableStickyHeader
enableVerticalBorders={tableConfig.enableVerticalBorders}
groups={groups}