Files
feishin/src/renderer/components/item-list/item-table-list/hooks/use-table-keyboard-navigation.ts
T
2026-01-16 23:45:46 -08:00

217 lines
8.2 KiB
TypeScript

import { useCallback } from 'react';
import {
ItemListStateActions,
ItemListStateItemWithRequiredProperties,
} from '/@/renderer/components/item-list/helpers/item-list-state';
import { TableItemProps } from '/@/renderer/components/item-list/item-table-list/item-table-list';
import { ItemControls } from '/@/renderer/components/item-list/types';
import { PlayerContext } from '/@/renderer/features/player/context/player-context';
import { LibraryItem } from '/@/shared/types/domain-types';
interface UseTableKeyboardNavigationProps {
calculateScrollTopForIndex: (index: number) => number;
cellPadding: TableItemProps['cellPadding'];
data: unknown[];
DEFAULT_ROW_HEIGHT: number;
enableHeader: boolean;
enableSelection: boolean;
extractRowId: (item: unknown) => string | undefined;
getItem?: (index: number) => undefined | unknown;
getItemIndex?: (rowId: string) => number | undefined;
getStateItem: (item: any) => ItemListStateItemWithRequiredProperties | null;
hasRequiredStateItemProperties: (
item: unknown,
) => item is ItemListStateItemWithRequiredProperties;
internalState: ItemListStateActions;
itemCount?: number;
itemType: LibraryItem;
parsedColumns: TableItemProps['columns'];
pinnedRightColumnCount: number;
pinnedRightColumnRef: React.RefObject<HTMLDivElement | null>;
playerContext: PlayerContext;
rowHeight: ((index: number, cellProps: TableItemProps) => number) | number | undefined;
rowRef: React.RefObject<HTMLDivElement | null>;
scrollToTableIndex: (index: number, options?: { align?: 'bottom' | 'center' | 'top' }) => void;
size: TableItemProps['size'];
tableId: string;
}
/**
* Hook to handle keyboard navigation (ArrowUp/ArrowDown) for table row selection and scrolling.
*/
export const useTableKeyboardNavigation = ({
calculateScrollTopForIndex,
cellPadding,
data,
DEFAULT_ROW_HEIGHT,
enableHeader,
enableSelection,
extractRowId,
getItem,
getItemIndex,
getStateItem,
hasRequiredStateItemProperties,
internalState,
itemCount,
itemType,
parsedColumns,
pinnedRightColumnCount,
pinnedRightColumnRef,
playerContext,
rowHeight,
rowRef,
scrollToTableIndex,
size,
tableId,
}: UseTableKeyboardNavigationProps) => {
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
if (!enableSelection) return;
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
e.preventDefault();
e.stopPropagation();
const selected = internalState.getSelected();
const validSelected = selected.filter(hasRequiredStateItemProperties);
let currentIndex = -1;
const totalCount = itemCount ?? data.length;
if (validSelected.length > 0) {
const lastSelected = validSelected[validSelected.length - 1];
const rowId = extractRowId(lastSelected);
if (rowId) {
currentIndex =
getItemIndex?.(rowId) ?? data.findIndex((d) => extractRowId(d) === rowId);
}
}
let newIndex = 0;
if (currentIndex !== -1) {
newIndex =
e.key === 'ArrowDown'
? Math.min(currentIndex + 1, totalCount - 1)
: Math.max(currentIndex - 1, 0);
}
const newItem: any = getItem ? getItem(newIndex) : data[newIndex];
if (!newItem) return;
const newItemListItem = getStateItem(newItem);
if (newItemListItem && extractRowId(newItemListItem)) {
internalState.setSelected([newItemListItem]);
}
// Check if we need to scroll by determining if the item is at the edge of the viewport
const gridIndex = enableHeader ? newIndex + 1 : newIndex;
const mainContainer = rowRef.current?.childNodes[0] as HTMLDivElement | undefined;
const pinnedRightContainer = pinnedRightColumnRef.current?.childNodes[0] as
| HTMLDivElement
| undefined;
// Use right pinned column scroll position if right-pinned columns exist
const scrollContainer =
pinnedRightColumnCount > 0 && pinnedRightContainer
? pinnedRightContainer
: mainContainer;
if (scrollContainer) {
const viewportTop = scrollContainer.scrollTop;
const viewportHeight = scrollContainer.clientHeight;
const viewportBottom = viewportTop + viewportHeight;
const rowTop = calculateScrollTopForIndex(gridIndex);
const adjustedIndex = enableHeader ? Math.max(0, newIndex - 1) : newIndex;
const mockCellProps: TableItemProps = {
cellPadding,
columns: parsedColumns,
controls: {} as ItemControls,
data: enableHeader ? [null] : [],
enableAlternateRowColors: false,
enableExpansion: false,
enableHeader,
enableHorizontalBorders: false,
enableRowHoverHighlight: false,
enableSelection,
enableVerticalBorders: false,
getRowHeight: () => DEFAULT_ROW_HEIGHT,
getRowItem: (rowIndex: number) => {
if (!getItem) return undefined;
if (enableHeader && rowIndex === 0) return null;
const dataIndex = enableHeader ? rowIndex - 1 : rowIndex;
return getItem(dataIndex);
},
internalState: {} as ItemListStateActions,
itemType,
playerContext,
size,
tableId,
};
let calculatedRowHeight: number;
if (typeof rowHeight === 'number') {
calculatedRowHeight = rowHeight;
} else if (typeof rowHeight === 'function') {
calculatedRowHeight = rowHeight(adjustedIndex, mockCellProps);
} else {
calculatedRowHeight = DEFAULT_ROW_HEIGHT;
}
const rowBottom = rowTop + calculatedRowHeight;
// Check if row is fully visible within viewport
const isFullyVisible = rowTop >= viewportTop && rowBottom <= viewportBottom;
// Check if row is at the edge (top or bottom of viewport)
const isAtTopEdge = rowTop < viewportTop;
const isAtBottomEdge = rowBottom >= viewportBottom;
// Only scroll if the item is not fully visible or at the edge
if (!isFullyVisible || isAtTopEdge || isAtBottomEdge) {
// Determine alignment based on direction
const align: 'bottom' | 'top' =
e.key === 'ArrowDown' && isAtBottomEdge
? 'bottom'
: e.key === 'ArrowUp' && isAtTopEdge
? 'top'
: isAtBottomEdge
? 'bottom'
: isAtTopEdge
? 'top'
: 'top';
scrollToTableIndex(gridIndex, { align });
}
}
},
[
calculateScrollTopForIndex,
cellPadding,
data,
getItem,
getItemIndex,
DEFAULT_ROW_HEIGHT,
enableHeader,
enableSelection,
extractRowId,
getStateItem,
hasRequiredStateItemProperties,
internalState,
itemCount,
itemType,
parsedColumns,
pinnedRightColumnCount,
pinnedRightColumnRef,
playerContext,
rowHeight,
rowRef,
scrollToTableIndex,
size,
tableId,
],
);
return { handleKeyDown };
};