add context to item list table

This commit is contained in:
jeffvli
2026-01-16 12:09:59 -08:00
parent 79e7d7a010
commit a5fa022eb6
5 changed files with 232 additions and 78 deletions
@@ -8,6 +8,7 @@ import {
TableColumnContainer, TableColumnContainer,
TableColumnTextContainer, TableColumnTextContainer,
} from '/@/renderer/components/item-list/item-table-list/item-table-list-column'; } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
import { useIsActiveRow } from '/@/renderer/components/item-list/item-table-list/item-table-list-context';
import { ItemListItem } from '/@/renderer/components/item-list/types'; import { ItemListItem } from '/@/renderer/components/item-list/types';
import { usePlayerStatus } from '/@/renderer/store'; import { usePlayerStatus } from '/@/renderer/store';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
@@ -87,9 +88,7 @@ const DefaultRowIndexColumn = (props: ItemTableListInnerColumn) => {
const QueueSongRowIndexColumn = (props: ItemTableListInnerColumn) => { const QueueSongRowIndexColumn = (props: ItemTableListInnerColumn) => {
const status = usePlayerStatus(); const status = usePlayerStatus();
const song = props.data[props.rowIndex] as QueueSong; const song = props.data[props.rowIndex] as QueueSong;
const isActive = const isActive = useIsActiveRow(song?.id, song?._uniqueId);
!!props.activeRowId &&
(props.activeRowId === song?.id || props.activeRowId === song?._uniqueId);
const isActiveAndPlaying = isActive && status === PlayerStatus.PLAYING; const isActiveAndPlaying = isActive && status === PlayerStatus.PLAYING;
@@ -10,6 +10,7 @@ import {
ItemTableListInnerColumn, ItemTableListInnerColumn,
TableColumnContainer, TableColumnContainer,
} from '/@/renderer/components/item-list/item-table-list/item-table-list-column'; } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
import { useIsActiveRow } from '/@/renderer/components/item-list/item-table-list/item-table-list-context';
import { Text } from '/@/shared/components/text/text'; import { Text } from '/@/shared/components/text/text';
import { LibraryItem, QueueSong } from '/@/shared/types/domain-types'; import { LibraryItem, QueueSong } from '/@/shared/types/domain-types';
@@ -75,9 +76,7 @@ function QueueSongTitleColumn(props: ItemTableListInnerColumn) {
]; ];
const song = props.data[props.rowIndex] as QueueSong; const song = props.data[props.rowIndex] as QueueSong;
const isActive = const isActive = useIsActiveRow(song?.id, song?._uniqueId);
!!props.activeRowId &&
(props.activeRowId === song?.id || props.activeRowId === song?._uniqueId);
if (typeof row === 'string') { if (typeof row === 'string') {
const path = getTitlePath(props.itemType, (props.data[props.rowIndex] as any).id as string); const path = getTitlePath(props.itemType, (props.data[props.rowIndex] as any).id as string);
@@ -12,6 +12,7 @@ import {
ItemTableListInnerColumn, ItemTableListInnerColumn,
TableColumnContainer, TableColumnContainer,
} from '/@/renderer/components/item-list/item-table-list/item-table-list-column'; } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
import { useIsActiveRow } from '/@/renderer/components/item-list/item-table-list/item-table-list-context';
import { JoinedArtists } from '/@/renderer/features/albums/components/joined-artists'; import { JoinedArtists } from '/@/renderer/features/albums/components/joined-artists';
import { PlayButton } from '/@/renderer/features/shared/components/play-button'; import { PlayButton } from '/@/renderer/features/shared/components/play-button';
import { import {
@@ -162,9 +163,7 @@ export const QueueSongTitleCombinedColumn = (props: ItemTableListInnerColumn) =>
const internalState = (props as any).internalState; const internalState = (props as any).internalState;
const playButtonBehavior = usePlayButtonBehavior(); const playButtonBehavior = usePlayButtonBehavior();
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const isActive = const isActive = useIsActiveRow(song?.id, song?._uniqueId);
!!props.activeRowId &&
(props.activeRowId === song?.id || props.activeRowId === song?._uniqueId);
const handlePlay = (playType: Play, event: React.MouseEvent<HTMLButtonElement>) => { const handlePlay = (playType: Play, event: React.MouseEvent<HTMLButtonElement>) => {
if (!item) { if (!item) {
@@ -0,0 +1,125 @@
import React, { createContext, useContext, useEffect, useMemo, useRef } from 'react';
import { useSyncExternalStore } from 'react';
import { ItemListStateActions } from '/@/renderer/components/item-list/helpers/item-list-state';
import { ItemControls, ItemTableListColumnConfig } from '/@/renderer/components/item-list/types';
import { PlayerContext } from '/@/renderer/features/player/context/player-context';
import { LibraryItem } from '/@/shared/types/domain-types';
/**
* Stage A/B: Provide table-scoped config + external stores so churny values can update
* without forcing `cellProps` identity changes (and therefore without rerendering every visible cell).
*/
export type ItemTableListConfig = {
cellPadding: 'lg' | 'md' | 'sm' | 'xl' | 'xs';
columns: ItemTableListColumnConfig[];
controls: ItemControls;
enableHeader: boolean;
enableRowHoverHighlight: boolean;
enableSelection: boolean;
internalState: ItemListStateActions;
itemType: LibraryItem;
playerContext: PlayerContext;
size: 'compact' | 'default' | 'large';
startRowIndex?: number;
tableId: string;
};
const ItemTableListConfigContext = createContext<ItemTableListConfig | null>(null);
export const ItemTableListConfigProvider = ({
children,
value,
}: {
children: React.ReactNode;
value: ItemTableListConfig;
}) => {
// Keep reference stable when the input reference is stable.
const memoValue = useMemo(() => value, [value]);
return (
<ItemTableListConfigContext.Provider value={memoValue}>
{children}
</ItemTableListConfigContext.Provider>
);
};
export const useItemTableListConfig = (): ItemTableListConfig | null => {
return useContext(ItemTableListConfigContext);
};
type ItemTableListStoreContextValue = {
activeRowStore: ActiveRowStore;
};
class ActiveRowStore {
private activeRowId: null | string = null;
private listeners = new Set<() => void>();
getActiveRowId(): null | string {
return this.activeRowId;
}
setActiveRowId(next: null | string | undefined): void {
const normalized = next ?? null;
if (this.activeRowId === normalized) return;
this.activeRowId = normalized;
this.listeners.forEach((l) => l());
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
}
}
const ItemTableListStoreContext = createContext<ItemTableListStoreContextValue | null>(null);
export const ItemTableListStoreProvider = ({
activeRowId,
children,
}: {
activeRowId?: string;
children: React.ReactNode;
}) => {
const storeRef = useRef<ActiveRowStore | null>(null);
if (!storeRef.current) {
storeRef.current = new ActiveRowStore();
}
const store = storeRef.current;
useEffect(() => {
store.setActiveRowId(activeRowId);
}, [activeRowId, store]);
const value = useMemo<ItemTableListStoreContextValue>(
() => ({ activeRowStore: store }),
[store],
);
return (
<ItemTableListStoreContext.Provider value={value}>
{children}
</ItemTableListStoreContext.Provider>
);
};
export const useItemTableListStore = (): ItemTableListStoreContextValue | null => {
return useContext(ItemTableListStoreContext);
};
export const useActiveRowSubscription = <T,>(selector: (activeRowId: null | string) => T): T => {
const store = useItemTableListStore()?.activeRowStore ?? null;
return useSyncExternalStore(store?.subscribe.bind(store) || (() => () => {}), () =>
selector(store?.getActiveRowId() ?? null),
);
};
export const useIsActiveRow = (...rowIds: Array<string | undefined>): boolean => {
return useActiveRowSubscription((activeRowId) =>
rowIds.some((id) => !!id && id === activeRowId),
);
};
@@ -36,6 +36,10 @@ import { parseTableColumns } from '/@/renderer/components/item-list/helpers/pars
import { useListHotkeys } from '/@/renderer/components/item-list/helpers/use-list-hotkeys'; import { useListHotkeys } from '/@/renderer/components/item-list/helpers/use-list-hotkeys';
import { useStickyTableGroupRows } from '/@/renderer/components/item-list/item-table-list/hooks/use-sticky-table-group-rows'; 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 { useStickyTableHeader } from '/@/renderer/components/item-list/item-table-list/hooks/use-sticky-table-header';
import {
ItemTableListConfigProvider,
ItemTableListStoreProvider,
} from '/@/renderer/components/item-list/item-table-list/item-table-list-context';
import { import {
ItemControls, ItemControls,
ItemListHandle, ItemListHandle,
@@ -88,7 +92,6 @@ enum TableItemSize {
} }
interface VirtualizedTableGridProps { interface VirtualizedTableGridProps {
activeRowId?: string;
calculatedColumnWidths: number[]; calculatedColumnWidths: number[];
CellComponent: JSXElementConstructor<CellComponentProps<TableItemProps>>; CellComponent: JSXElementConstructor<CellComponentProps<TableItemProps>>;
cellPadding: 'lg' | 'md' | 'sm' | 'xl' | 'xs'; cellPadding: 'lg' | 'md' | 'sm' | 'xl' | 'xs';
@@ -132,7 +135,6 @@ interface VirtualizedTableGridProps {
} }
const VirtualizedTableGrid = ({ const VirtualizedTableGrid = ({
activeRowId,
calculatedColumnWidths, calculatedColumnWidths,
CellComponent, CellComponent,
cellPadding, cellPadding,
@@ -442,7 +444,6 @@ const VirtualizedTableGrid = ({
const dynamicDataProps = useMemo( const dynamicDataProps = useMemo(
() => ({ () => ({
activeRowId,
calculatedColumnWidths, calculatedColumnWidths,
data: dataWithGroups, data: dataWithGroups,
getAdjustedRowIndex, getAdjustedRowIndex,
@@ -455,7 +456,6 @@ const VirtualizedTableGrid = ({
startRowIndex, startRowIndex,
}), }),
[ [
activeRowId,
calculatedColumnWidths, calculatedColumnWidths,
dataWithGroups, dataWithGroups,
getAdjustedRowIndex, getAdjustedRowIndex,
@@ -779,7 +779,6 @@ VirtualizedTableGrid.displayName = 'VirtualizedTableGrid';
const MemoizedVirtualizedTableGrid = memo(VirtualizedTableGrid, (prevProps, nextProps) => { const MemoizedVirtualizedTableGrid = memo(VirtualizedTableGrid, (prevProps, nextProps) => {
return ( return (
prevProps.activeRowId === nextProps.activeRowId &&
prevProps.calculatedColumnWidths === nextProps.calculatedColumnWidths && prevProps.calculatedColumnWidths === nextProps.calculatedColumnWidths &&
prevProps.cellPadding === nextProps.cellPadding && prevProps.cellPadding === nextProps.cellPadding &&
prevProps.controls === nextProps.controls && prevProps.controls === nextProps.controls &&
@@ -837,7 +836,6 @@ export interface TableGroupHeader {
} }
export interface TableItemProps { export interface TableItemProps {
activeRowId?: string;
adjustedRowIndexMap?: Map<number, number>; adjustedRowIndexMap?: Map<number, number>;
calculatedColumnWidths?: number[]; calculatedColumnWidths?: number[];
cellPadding?: ItemTableListProps['cellPadding']; cellPadding?: ItemTableListProps['cellPadding'];
@@ -2531,70 +2529,104 @@ const BaseItemTableList = ({
itemType, itemType,
}); });
const tableConfigValue = useMemo(
() => ({
cellPadding,
columns: parsedColumns,
controls,
enableHeader,
enableRowHoverHighlight,
enableSelection,
internalState,
itemType,
playerContext,
size,
startRowIndex,
tableId,
}),
[
cellPadding,
parsedColumns,
controls,
enableHeader,
enableRowHoverHighlight,
enableSelection,
internalState,
itemType,
playerContext,
size,
startRowIndex,
tableId,
],
);
return ( return (
<motion.div <ItemTableListStoreProvider activeRowId={activeRowId}>
className={styles.itemTableListContainer} <ItemTableListConfigProvider value={tableConfigValue}>
onKeyDown={handleKeyDown} <motion.div
onMouseDown={(e) => { className={styles.itemTableListContainer}
const element = e.currentTarget as HTMLDivElement; onKeyDown={handleKeyDown}
// Focus without scrolling into view onMouseDown={(e) => {
if (element.focus) { const element = e.currentTarget as HTMLDivElement;
element.focus({ preventScroll: true }); // Focus without scrolling into view
} if (element.focus) {
}} element.focus({ preventScroll: true });
ref={mergedContainerRef} }
tabIndex={0} }}
{...animationProps.fadeIn} ref={mergedContainerRef}
transition={{ duration: enableEntranceAnimation ? 1 : 0, ease: 'anticipate' }} tabIndex={0}
> {...animationProps.fadeIn}
{StickyHeader} transition={{ duration: enableEntranceAnimation ? 1 : 0, ease: 'anticipate' }}
{StickyGroupRow} >
<MemoizedVirtualizedTableGrid {StickyHeader}
activeRowId={activeRowId} {StickyGroupRow}
calculatedColumnWidths={calculatedColumnWidths} <MemoizedVirtualizedTableGrid
CellComponent={CellComponent} calculatedColumnWidths={calculatedColumnWidths}
cellPadding={cellPadding} CellComponent={CellComponent}
controls={controls} cellPadding={cellPadding}
data={data} controls={controls}
dataWithGroups={dataWithGroups} data={data}
enableAlternateRowColors={enableAlternateRowColors} dataWithGroups={dataWithGroups}
enableColumnReorder={!!onColumnReordered} enableAlternateRowColors={enableAlternateRowColors}
enableColumnResize={!!onColumnResized} enableColumnReorder={!!onColumnReordered}
enableDrag={enableDrag} enableColumnResize={!!onColumnResized}
enableExpansion={enableExpansion} enableDrag={enableDrag}
enableHeader={enableHeader} enableExpansion={enableExpansion}
enableHorizontalBorders={enableHorizontalBorders} enableHeader={enableHeader}
enableRowHoverHighlight={enableRowHoverHighlight} enableHorizontalBorders={enableHorizontalBorders}
enableScrollShadow={enableScrollShadow} enableRowHoverHighlight={enableRowHoverHighlight}
enableSelection={enableSelection} enableScrollShadow={enableScrollShadow}
enableVerticalBorders={enableVerticalBorders} enableSelection={enableSelection}
getRowHeight={getRowHeight} enableVerticalBorders={enableVerticalBorders}
groups={groups} getRowHeight={getRowHeight}
headerHeight={headerHeight} groups={groups}
internalState={internalState} headerHeight={headerHeight}
itemType={itemType} internalState={internalState}
mergedRowRef={mergedRowRef} itemType={itemType}
onRangeChanged={onRangeChanged} mergedRowRef={mergedRowRef}
parsedColumns={parsedColumns} onRangeChanged={onRangeChanged}
pinnedLeftColumnCount={pinnedLeftColumnCount} parsedColumns={parsedColumns}
pinnedLeftColumnRef={pinnedLeftColumnRef} pinnedLeftColumnCount={pinnedLeftColumnCount}
pinnedRightColumnCount={pinnedRightColumnCount} pinnedLeftColumnRef={pinnedLeftColumnRef}
pinnedRightColumnRef={pinnedRightColumnRef} pinnedRightColumnCount={pinnedRightColumnCount}
pinnedRowCount={pinnedRowCount} pinnedRightColumnRef={pinnedRightColumnRef}
pinnedRowRef={pinnedRowRef} pinnedRowCount={pinnedRowCount}
playerContext={playerContext} pinnedRowRef={pinnedRowRef}
showLeftShadow={showLeftShadow} playerContext={playerContext}
showRightShadow={showRightShadow} showLeftShadow={showLeftShadow}
showTopShadow={showTopShadow} showRightShadow={showRightShadow}
size={size} showTopShadow={showTopShadow}
startRowIndex={startRowIndex} size={size}
tableId={tableId} startRowIndex={startRowIndex}
totalColumnCount={totalColumnCount} tableId={tableId}
totalRowCount={totalRowCount} totalColumnCount={totalColumnCount}
/> totalRowCount={totalRowCount}
<ExpandedContainer internalState={internalState} itemType={itemType} /> />
{/* {enableSelectionDialog && <SelectionDialog internalState={internalState} />} */} <ExpandedContainer internalState={internalState} itemType={itemType} />
</motion.div> {/* {enableSelectionDialog && <SelectionDialog internalState={internalState} />} */}
</motion.div>
</ItemTableListConfigProvider>
</ItemTableListStoreProvider>
); );
}; };