more table list progress

- scroll shadow
- header shadow
- remove sticky row count - only allow sticky header
- support table expansion
This commit is contained in:
jeffvli
2025-10-03 17:50:23 -07:00
parent 8e392a9bff
commit f1f3223922
2 changed files with 214 additions and 117 deletions
@@ -1,3 +1,11 @@
.item-table-list-container {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
overflow: hidden;
}
.item-table-container { .item-table-container {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@@ -8,6 +16,7 @@
} }
.item-table-grid-container { .item-table-grid-container {
position: relative;
flex: 1 1 auto; flex: 1 1 auto;
width: 100%; width: 100%;
height: 100%; height: 100%;
@@ -23,6 +32,7 @@
} }
.item-table-sticky-rows-grid-container { .item-table-sticky-rows-grid-container {
position: relative;
flex: 0 1 auto; flex: 0 1 auto;
min-width: 0; min-width: 0;
} }
@@ -35,6 +45,7 @@
} }
.item-table-sticky-intersection-grid-container { .item-table-sticky-intersection-grid-container {
position: relative;
flex: 0 1 auto; flex: 0 1 auto;
min-width: 0; min-width: 0;
} }
@@ -53,3 +64,39 @@
.height-100 { .height-100 {
height: 100%; height: 100%;
} }
.item-table-sticky-header-shadow {
position: absolute;
top: 100%;
right: 0;
left: 0;
z-index: 1;
height: 8px;
pointer-events: none;
background: linear-gradient(
to bottom,
rgb(0 0 0 / 50%) 0%,
rgb(0 0 0 / 5%) 50%,
transparent 100%
);
}
.item-table-left-scroll-shadow {
position: absolute;
top: 0;
bottom: 0;
left: 0;
z-index: 1;
width: 8px;
pointer-events: none;
background: linear-gradient(
to right,
rgb(0 0 0 / 50%) 0%,
rgb(0 0 0 / 5%) 50%,
transparent 100%
);
}
.list-expanded-container {
height: 500px;
}
@@ -12,6 +12,7 @@ import {
useCallback, useCallback,
useEffect, useEffect,
useRef, useRef,
useState,
} from 'react'; } from 'react';
import { type CellComponentProps, Grid, GridImperativeAPI, type GridProps } from 'react-window-v2'; import { type CellComponentProps, Grid, GridImperativeAPI, type GridProps } from 'react-window-v2';
@@ -21,17 +22,20 @@ import { ExpandedListItem } from '/@/renderer/components/item-list/expanded-list
import { useItemListState } from '/@/renderer/components/item-list/helpers/item-list-state'; import { useItemListState } from '/@/renderer/components/item-list/helpers/item-list-state';
import { LibraryItem } from '/@/shared/types/domain-types'; import { LibraryItem } from '/@/shared/types/domain-types';
export interface CellProps {
columns: ItemTableListColumn[];
data: unknown[];
handleExpand: (e: MouseEvent<HTMLDivElement>, item: unknown, itemType: LibraryItem) => void;
itemType: LibraryItem;
size?: 'compact' | 'default';
}
export interface ItemTableListColumn { export interface ItemTableListColumn {
id: string; id: string;
label: string; label: string;
width: number; width: number;
} }
interface CellProps {
columns: ItemTableListColumn[];
data: unknown[];
}
interface ItemTableListProps { interface ItemTableListProps {
CellComponent: JSXElementConstructor<CellComponentProps<CellProps>>; CellComponent: JSXElementConstructor<CellComponentProps<CellProps>>;
columnCount: number; columnCount: number;
@@ -39,7 +43,9 @@ interface ItemTableListProps {
columnWidth: ((index: number, cellProps: CellProps) => number) | number; columnWidth: ((index: number, cellProps: CellProps) => number) | number;
data: unknown[]; data: unknown[];
enableExpansion?: boolean; enableExpansion?: boolean;
enableHeader?: boolean;
enableSelection?: boolean; enableSelection?: boolean;
headerHeight?: number;
initialTopMostItemIndex?: initialTopMostItemIndex?:
| number | number
| { | {
@@ -60,8 +66,8 @@ interface ItemTableListProps {
onStartReached?: (index: number) => void; onStartReached?: (index: number) => void;
ref?: Ref<GridImperativeAPI>; ref?: Ref<GridImperativeAPI>;
rowHeight: ((index: number, cellProps: CellProps) => number) | number; rowHeight: ((index: number, cellProps: CellProps) => number) | number;
size?: 'compact' | 'default';
stickyColumnCount: number; stickyColumnCount: number;
stickyRowCount: number;
totalItemCount: number; totalItemCount: number;
} }
@@ -85,6 +91,8 @@ export const ItemTableList = ({
columns, columns,
columnWidth, columnWidth,
data, data,
enableHeader = true,
headerHeight = 40,
initialTopMostItemIndex, initialTopMostItemIndex,
itemType, itemType,
onCellsRendered, onCellsRendered,
@@ -98,10 +106,11 @@ export const ItemTableList = ({
onStartReached, onStartReached,
ref, ref,
rowHeight, rowHeight,
size = 'default',
stickyColumnCount, stickyColumnCount,
stickyRowCount,
totalItemCount, totalItemCount,
}: ItemTableListProps) => { }: ItemTableListProps) => {
const stickyRowCount = enableHeader ? 1 : 0;
const totalRowCount = totalItemCount - (stickyRowCount ?? 0); const totalRowCount = totalItemCount - (stickyRowCount ?? 0);
const totalColumnCount = columnCount - (stickyColumnCount ?? 0); const totalColumnCount = columnCount - (stickyColumnCount ?? 0);
const stickyRowRef = useRef<HTMLDivElement>(null); const stickyRowRef = useRef<HTMLDivElement>(null);
@@ -109,6 +118,7 @@ export const ItemTableList = ({
const stickyColumnRef = useRef<HTMLDivElement>(null); const stickyColumnRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement | null>(null); const scrollContainerRef = useRef<HTMLDivElement | null>(null);
const mergedRowRef = useMergedRef(rowRef, scrollContainerRef); const mergedRowRef = useMergedRef(rowRef, scrollContainerRef);
const [showLeftShadow, setShowLeftShadow] = useState(false);
const [initialize] = useOverlayScrollbars({ const [initialize] = useOverlayScrollbars({
defer: true, defer: true,
@@ -321,10 +331,43 @@ export const ItemTableList = ({
return undefined; return undefined;
}, []); }, []);
const cellProps = { // Handle left shadow visibility based on horizontal scroll
columns, useEffect(() => {
data, const row = rowRef.current?.childNodes[0] as HTMLDivElement;
};
if (!row || !stickyColumnCount) {
setShowLeftShadow(false);
return;
}
const checkScrollPosition = () => {
const scrollLeft = row.scrollLeft;
setShowLeftShadow(scrollLeft > 0);
};
checkScrollPosition();
row.addEventListener('scroll', checkScrollPosition);
return () => {
row.removeEventListener('scroll', checkScrollPosition);
};
}, [stickyColumnCount]);
const getRowHeight = useCallback(
(index: number, cellProps: CellProps) => {
const baseHeight =
typeof rowHeight === 'number' ? rowHeight : rowHeight(index, cellProps);
// If enableHeader is true and this is the first sticky row, use fixed header height
if (enableHeader && index === 0 && stickyRowCount > 0) {
return headerHeight;
}
return baseHeight;
},
[enableHeader, headerHeight, rowHeight, stickyRowCount],
);
const internalState = useItemListState(); const internalState = useItemListState();
@@ -342,6 +385,7 @@ export const ItemTableList = ({
}, },
[internalState], [internalState],
); );
const handleOnCellsRendered = useCallback( const handleOnCellsRendered = useCallback(
(cells: { (cells: {
columnStartIndex: number; columnStartIndex: number;
@@ -349,6 +393,11 @@ export const ItemTableList = ({
rowStartIndex: number; rowStartIndex: number;
rowStopIndex: number; rowStopIndex: number;
}) => { }) => {
onRangeChanged?.({
endIndex: cells.rowStopIndex,
startIndex: cells.rowStartIndex,
});
return onCellsRendered return onCellsRendered
? ({ columnStartIndex, columnStopIndex, rowStartIndex, rowStopIndex }) => { ? ({ columnStartIndex, columnStopIndex, rowStartIndex, rowStopIndex }) => {
return onCellsRendered!( return onCellsRendered!(
@@ -363,7 +412,7 @@ export const ItemTableList = ({
} }
: undefined; : undefined;
}, },
[onCellsRendered, stickyColumnCount, stickyRowCount], [onCellsRendered, onRangeChanged, stickyColumnCount, stickyRowCount],
); );
const StickyRowCell = useCallback( const StickyRowCell = useCallback(
@@ -406,6 +455,14 @@ export const ItemTableList = ({
[stickyColumnCount, stickyRowCount, CellComponent], [stickyColumnCount, stickyRowCount, CellComponent],
); );
const cellProps = {
columns,
data,
handleExpand,
itemType,
size,
};
return ( return (
<motion.div <motion.div
animate={{ animate={{
@@ -416,128 +473,121 @@ export const ItemTableList = ({
ease: 'backInOut', ease: 'backInOut',
}, },
}} }}
className={styles.itemTableContainer} className={styles.itemTableListContainer}
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
> >
<div <div className={styles.itemTableContainer}>
className={styles.itemTableStickyColumnsGridContainer} <div
style={{ className={styles.itemTableStickyColumnsGridContainer}
minWidth: `${Array.from({ length: stickyColumnCount ?? 0 }, () => 0).reduce( style={{
(a, _, i) => minWidth: `${Array.from({ length: stickyColumnCount ?? 0 }, () => 0).reduce(
a + (a, _, i) =>
(typeof columnWidth === 'number' a +
? columnWidth (typeof columnWidth === 'number'
: columnWidth(i, cellProps)), ? columnWidth
0, : columnWidth(i, cellProps)),
)}px`, 0,
}} )}px`,
> }}
{!!(stickyColumnCount || stickyRowCount) && ( >
<div {!!(stickyColumnCount || stickyRowCount) && (
className={styles.itemTableStickyIntersectionGridContainer} <div
style={{ className={styles.itemTableStickyIntersectionGridContainer}
minHeight: `${Array.from( style={{
{ length: stickyRowCount ?? 0 }, minHeight: `${Array.from(
() => 0, { length: stickyRowCount ?? 0 },
).reduce( () => 0,
(a, _, i) => ).reduce((a, _, i) => a + getRowHeight(i, cellProps), 0)}px`,
a +
(typeof rowHeight === 'number'
? rowHeight
: rowHeight(i, cellProps)),
0,
)}px`,
}}
>
<Grid
cellComponent={CellComponent as any}
cellProps={cellProps}
className={styles.noScrollbar}
columnCount={stickyColumnCount}
columnWidth={columnWidth}
rowCount={stickyRowCount}
rowHeight={rowHeight}
/>
</div>
)}
{!!stickyColumnCount && (
<div className={styles.itemTableStickyColumnsContainer} ref={stickyColumnRef}>
<Grid
cellComponent={StickyColumnCell}
cellProps={cellProps}
className={clsx(styles.noScrollbar, styles.height100)}
columnCount={stickyColumnCount}
columnWidth={columnWidth}
rowCount={totalRowCount}
rowHeight={(index, cellProps) => {
return typeof rowHeight === 'number'
? rowHeight
: rowHeight(index + (stickyRowCount ?? 0), cellProps);
}} }}
/> >
</div> <Grid
)} cellComponent={CellComponent as any}
</div> cellProps={cellProps}
<div className={styles.itemTableStickyRowsContainer}> className={styles.noScrollbar}
{!!stickyRowCount && ( columnCount={stickyColumnCount}
<div columnWidth={columnWidth}
className={styles.itemTableStickyRowsGridContainer} rowCount={stickyRowCount}
ref={stickyRowRef} rowHeight={getRowHeight}
style={{ />
minHeight: `${Array.from( {enableHeader && <div className={styles.itemTableStickyHeaderShadow} />}
{ length: stickyRowCount ?? 0 }, </div>
() => 0, )}
).reduce( {!!stickyColumnCount && (
(a, _, i) => <div
a + className={styles.itemTableStickyColumnsContainer}
(typeof rowHeight === 'number' ref={stickyColumnRef}
? rowHeight >
: rowHeight(i, cellProps)), <Grid
0, cellComponent={StickyColumnCell}
)}px`, cellProps={cellProps}
}} className={clsx(styles.noScrollbar, styles.height100)}
> columnCount={stickyColumnCount}
columnWidth={columnWidth}
rowCount={totalRowCount}
rowHeight={(index, cellProps) => {
return getRowHeight(index + (stickyRowCount ?? 0), cellProps);
}}
/>
</div>
)}
</div>
<div className={styles.itemTableStickyRowsContainer}>
{!!stickyRowCount && (
<div
className={styles.itemTableStickyRowsGridContainer}
ref={stickyRowRef}
style={
{
'--header-height': `${headerHeight}px`,
minHeight: `${Array.from(
{ length: stickyRowCount ?? 0 },
() => 0,
).reduce((a, _, i) => a + getRowHeight(i, cellProps), 0)}px`,
} as React.CSSProperties
}
>
<Grid
cellComponent={StickyRowCell}
cellProps={cellProps}
className={styles.noScrollbar}
columnCount={totalColumnCount}
columnWidth={(index, cellProps) => {
return typeof columnWidth === 'number'
? columnWidth
: columnWidth(index + (stickyColumnCount ?? 0), cellProps);
}}
rowCount={
Array.from({ length: stickyRowCount ?? 0 }, () => 0).length
}
rowHeight={getRowHeight}
/>
{enableHeader && <div className={styles.itemTableStickyHeaderShadow} />}
</div>
)}
<div className={styles.itemTableGridContainer} ref={mergedRowRef}>
<Grid <Grid
cellComponent={StickyRowCell} cellComponent={RowCell}
cellProps={cellProps} cellProps={cellProps}
className={styles.noScrollbar} className={styles.height100}
columnCount={totalColumnCount} columnCount={totalColumnCount}
columnWidth={(index, cellProps) => { columnWidth={(index, cellProps) => {
return typeof columnWidth === 'number' return typeof columnWidth === 'number'
? columnWidth ? columnWidth
: columnWidth(index + (stickyColumnCount ?? 0), cellProps); : columnWidth(index + (stickyColumnCount ?? 0), cellProps);
}} }}
rowCount={Array.from({ length: stickyRowCount ?? 0 }, () => 0).length} onCellsRendered={handleOnCellsRendered}
rowCount={totalRowCount}
rowHeight={(index, cellProps) => { rowHeight={(index, cellProps) => {
return typeof rowHeight === 'number' return getRowHeight(index + (stickyRowCount ?? 0), cellProps);
? rowHeight
: rowHeight(index, cellProps);
}} }}
/> />
{stickyColumnCount > 0 && showLeftShadow && (
<div className={styles.itemTableLeftScrollShadow} />
)}
</div> </div>
)}
<div className={styles.itemTableGridContainer} ref={mergedRowRef}>
<Grid
cellComponent={RowCell}
cellProps={cellProps}
className={styles.height100}
columnCount={totalColumnCount}
columnWidth={(index, cellProps) => {
return typeof columnWidth === 'number'
? columnWidth
: columnWidth(index + (stickyColumnCount ?? 0), cellProps);
}}
onCellsRendered={handleOnCellsRendered}
rowCount={totalRowCount}
rowHeight={(index, cellProps) => {
return typeof rowHeight === 'number'
? rowHeight
: rowHeight(index + (stickyRowCount ?? 0), cellProps);
}}
/>
</div> </div>
</div> </div>
<AnimatePresence> <AnimatePresence initial={false}>
{hasExpanded && ( {hasExpanded && (
<motion.div <motion.div
animate="show" animate="show"