mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-07 04:20:12 +02:00
add keyboard navigation and selection to lists
This commit is contained in:
@@ -3,6 +3,8 @@
|
||||
flex-direction: column !important;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
outline: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.auto-sizer-container {
|
||||
|
||||
@@ -11,6 +11,7 @@ import React, {
|
||||
RefObject,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
@@ -35,6 +36,7 @@ import { ExpandedListContainer } from '/@/renderer/components/item-list/expanded
|
||||
import { ExpandedListItem } from '/@/renderer/components/item-list/expanded-list-item';
|
||||
import { useDefaultItemListControls } from '/@/renderer/components/item-list/helpers/item-list-controls';
|
||||
import {
|
||||
ItemListItem,
|
||||
ItemListStateActions,
|
||||
useItemListState,
|
||||
} from '/@/renderer/components/item-list/helpers/item-list-state';
|
||||
@@ -271,7 +273,9 @@ export const ItemGridList = ({
|
||||
const outerRef = useRef(null);
|
||||
const listRef = useRef<FixedSizeList<GridItemProps>>(null);
|
||||
const { ref: containerRef, width: containerWidth } = useElementSize();
|
||||
const mergedContainerRef = useMergedRef(containerRef, rootRef);
|
||||
const containerFocusRef = useRef<HTMLDivElement | null>(null);
|
||||
const handleRef = useRef<ItemListHandle | null>(null);
|
||||
const mergedContainerRef = useMergedRef(containerRef, rootRef, containerFocusRef);
|
||||
|
||||
const getDataFn = useCallback(() => {
|
||||
return data;
|
||||
@@ -332,11 +336,254 @@ export const ItemGridList = ({
|
||||
|
||||
const controls = useDefaultItemListControls();
|
||||
|
||||
// Scroll to a specific index
|
||||
const scrollToIndex = useCallback(
|
||||
(index: number) => {
|
||||
if (!listRef.current || !tableMeta) return;
|
||||
const row = Math.floor(index / tableMeta.columnCount);
|
||||
listRef.current.scrollToItem(row, 'smart');
|
||||
},
|
||||
[tableMeta],
|
||||
);
|
||||
|
||||
// Scroll to a specific offset
|
||||
const scrollToOffset = useCallback((offset: number) => {
|
||||
if (!listRef.current) return;
|
||||
listRef.current.scrollTo(offset);
|
||||
}, []);
|
||||
|
||||
// Handle keyboard navigation
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
console.log('handleKeyDown', e.key);
|
||||
if (!enableSelection || !tableMeta) return;
|
||||
if (
|
||||
e.key !== 'ArrowDown' &&
|
||||
e.key !== 'ArrowUp' &&
|
||||
e.key !== 'ArrowLeft' &&
|
||||
e.key !== 'ArrowRight'
|
||||
)
|
||||
return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const selected = internalState.getSelected();
|
||||
let currentIndex = -1;
|
||||
|
||||
if (selected.length > 0) {
|
||||
const lastSelected = selected[selected.length - 1];
|
||||
currentIndex = data.findIndex(
|
||||
(d: any) => d && typeof d === 'object' && 'id' in d && d.id === lastSelected.id,
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate grid position
|
||||
const currentRow =
|
||||
currentIndex !== -1 ? Math.floor(currentIndex / tableMeta.columnCount) : 0;
|
||||
const currentCol = currentIndex !== -1 ? currentIndex % tableMeta.columnCount : 0;
|
||||
const totalRows = Math.ceil(data.length / tableMeta.columnCount);
|
||||
|
||||
let newIndex = 0;
|
||||
if (currentIndex !== -1) {
|
||||
switch (e.key) {
|
||||
case 'ArrowDown': {
|
||||
// Move down one row
|
||||
const nextRow = currentRow + 1;
|
||||
if (nextRow < totalRows) {
|
||||
const nextRowStart = nextRow * tableMeta.columnCount;
|
||||
const nextRowEnd = Math.min(
|
||||
nextRowStart + tableMeta.columnCount - 1,
|
||||
data.length - 1,
|
||||
);
|
||||
// Keep same column position, or use last item in row if column doesn't exist
|
||||
newIndex = Math.min(nextRowStart + currentCol, nextRowEnd);
|
||||
} else {
|
||||
newIndex = currentIndex;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'ArrowLeft': {
|
||||
// Move left, wrap to previous row if at start of row
|
||||
if (currentCol > 0) {
|
||||
newIndex = currentIndex - 1;
|
||||
} else if (currentRow > 0) {
|
||||
// Wrap to end of previous row
|
||||
newIndex = Math.max(
|
||||
(currentRow - 1) * tableMeta.columnCount +
|
||||
tableMeta.columnCount -
|
||||
1,
|
||||
0,
|
||||
);
|
||||
newIndex = Math.min(newIndex, data.length - 1);
|
||||
} else {
|
||||
newIndex = currentIndex;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'ArrowRight': {
|
||||
// Move right, wrap to next row if at end of row
|
||||
if (
|
||||
currentCol < tableMeta.columnCount - 1 &&
|
||||
currentIndex < data.length - 1
|
||||
) {
|
||||
newIndex = currentIndex + 1;
|
||||
} else if (currentRow < totalRows - 1) {
|
||||
// Wrap to start of next row
|
||||
newIndex = Math.min(
|
||||
(currentRow + 1) * tableMeta.columnCount,
|
||||
data.length - 1,
|
||||
);
|
||||
} else {
|
||||
newIndex = currentIndex;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'ArrowUp': {
|
||||
// Move up one row
|
||||
const prevRow = currentRow - 1;
|
||||
if (prevRow >= 0) {
|
||||
const prevRowStart = prevRow * tableMeta.columnCount;
|
||||
const prevRowEnd = Math.min(
|
||||
prevRowStart + tableMeta.columnCount - 1,
|
||||
data.length - 1,
|
||||
);
|
||||
// Keep same column position, or use last item in row if column doesn't exist
|
||||
newIndex = Math.min(prevRowStart + currentCol, prevRowEnd);
|
||||
} else {
|
||||
newIndex = currentIndex;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No selection, start at first item
|
||||
newIndex = 0;
|
||||
}
|
||||
|
||||
const newItem: any = data[newIndex];
|
||||
if (!newItem) return;
|
||||
|
||||
// Handle Shift + Arrow for incremental range selection (matches shift+click behavior)
|
||||
if (e.shiftKey) {
|
||||
const selectedItems = internalState.getSelected();
|
||||
const lastSelectedItem = selectedItems[selectedItems.length - 1];
|
||||
|
||||
if (lastSelectedItem) {
|
||||
// Find the indices of the last selected item and new item
|
||||
const lastIndex = data.findIndex(
|
||||
(d: any) =>
|
||||
d && typeof d === 'object' && 'id' in d && d.id === lastSelectedItem.id,
|
||||
);
|
||||
|
||||
if (lastIndex !== -1 && newIndex !== -1) {
|
||||
// Create range selection from last selected to new position
|
||||
const startIndex = Math.min(lastIndex, newIndex);
|
||||
const stopIndex = Math.max(lastIndex, newIndex);
|
||||
|
||||
const rangeItems: ItemListItem[] = [];
|
||||
for (let i = startIndex; i <= stopIndex; i++) {
|
||||
const rangeItem = data[i];
|
||||
if (
|
||||
rangeItem &&
|
||||
typeof rangeItem === 'object' &&
|
||||
'id' in rangeItem &&
|
||||
'serverId' in rangeItem
|
||||
) {
|
||||
rangeItems.push({
|
||||
_serverId: (rangeItem as any).serverId,
|
||||
id: (rangeItem as any).id,
|
||||
itemType,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add range items to selection (matching shift+click behavior)
|
||||
const currentSelected = internalState.getSelected();
|
||||
const newSelected = [...currentSelected];
|
||||
rangeItems.forEach((rangeItem) => {
|
||||
if (!newSelected.some((selected) => selected.id === rangeItem.id)) {
|
||||
newSelected.push(rangeItem);
|
||||
}
|
||||
});
|
||||
|
||||
// Ensure the last item in selection is the item at newIndex for incremental extension
|
||||
const newItemListItem: ItemListItem = {
|
||||
_serverId: newItem.serverId,
|
||||
id: newItem.id,
|
||||
itemType,
|
||||
};
|
||||
// Remove the new item from its current position if it exists
|
||||
const filteredSelected = newSelected.filter(
|
||||
(item) => item.id !== newItemListItem.id,
|
||||
);
|
||||
// Add it at the end so it becomes the last selected item
|
||||
filteredSelected.push(newItemListItem);
|
||||
internalState.setSelected(filteredSelected);
|
||||
}
|
||||
} else {
|
||||
// No previous selection, just select the new item
|
||||
internalState.setSelected([
|
||||
{
|
||||
_serverId: newItem.serverId,
|
||||
id: newItem.id,
|
||||
itemType,
|
||||
},
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
// Without Shift: select only the new item
|
||||
internalState.setSelected([
|
||||
{
|
||||
_serverId: newItem.serverId,
|
||||
id: newItem.id,
|
||||
itemType,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
scrollToIndex(newIndex);
|
||||
},
|
||||
[data, enableSelection, internalState, itemType, tableMeta, scrollToIndex],
|
||||
);
|
||||
|
||||
// Create imperative handle
|
||||
const imperativeHandle: ItemListHandle = useMemo(() => {
|
||||
return {
|
||||
clearExpanded: () => {
|
||||
internalState.clearExpanded();
|
||||
},
|
||||
clearSelected: () => {
|
||||
internalState.clearSelected();
|
||||
},
|
||||
getItem: (index: number) => data[index],
|
||||
getItemCount: () => data.length,
|
||||
getItems: () => data,
|
||||
internalState,
|
||||
scrollToIndex: (index: number) => {
|
||||
scrollToIndex(index);
|
||||
},
|
||||
scrollToOffset: (offset: number) => {
|
||||
scrollToOffset(offset);
|
||||
},
|
||||
};
|
||||
}, [data, internalState, scrollToIndex, scrollToOffset]);
|
||||
|
||||
// Expose handle via ref
|
||||
useEffect(() => {
|
||||
handleRef.current = imperativeHandle;
|
||||
}, [imperativeHandle]);
|
||||
|
||||
// Expose handle via forwardRef
|
||||
useImperativeHandle(ref, () => imperativeHandle, [imperativeHandle]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.itemGridContainer}
|
||||
data-overlayscrollbars-initialize=""
|
||||
onKeyDown={handleKeyDown}
|
||||
onMouseDown={(e) => (e.currentTarget as HTMLDivElement).focus()}
|
||||
ref={mergedContainerRef}
|
||||
tabIndex={0}
|
||||
>
|
||||
<VirtualizedGridList
|
||||
controls={controls}
|
||||
|
||||
@@ -556,6 +556,7 @@ export const ItemTableList = ({
|
||||
const [showLeftShadow, setShowLeftShadow] = useState(false);
|
||||
const [showRightShadow, setShowRightShadow] = useState(false);
|
||||
const handleRef = useRef<ItemListHandle | null>(null);
|
||||
const containerFocusRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const onScrollEndRef = useRef<ItemTableListProps['onScrollEnd']>(onScrollEnd);
|
||||
useEffect(() => {
|
||||
@@ -582,17 +583,61 @@ export const ItemTableList = ({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const DEFAULT_ROW_HEIGHT = size === 'compact' ? 40 : size === 'large' ? 88 : 64;
|
||||
|
||||
const calculateScrollTopForIndex = useCallback(
|
||||
(index: number) => {
|
||||
const adjustedIndex = enableHeader ? Math.max(0, index - 1) : index;
|
||||
let scrollTop = 0;
|
||||
|
||||
// Create a minimal mock cellProps for rowHeight function calls if needed
|
||||
const mockCellProps: TableItemProps = {
|
||||
cellPadding,
|
||||
columns: parsedColumns,
|
||||
controls: {} as ItemControls,
|
||||
data: enableHeader ? [null, ...data] : data,
|
||||
enableAlternateRowColors,
|
||||
enableExpansion,
|
||||
enableHeader,
|
||||
enableHorizontalBorders,
|
||||
enableRowHoverHighlight,
|
||||
enableSelection,
|
||||
enableVerticalBorders,
|
||||
getRowHeight: () => DEFAULT_ROW_HEIGHT,
|
||||
internalState: {} as ItemListStateActions,
|
||||
itemType,
|
||||
size,
|
||||
};
|
||||
|
||||
for (let i = 0; i < adjustedIndex; i++) {
|
||||
const height = rowHeight as number;
|
||||
let height: number;
|
||||
if (typeof rowHeight === 'number') {
|
||||
height = rowHeight;
|
||||
} else if (typeof rowHeight === 'function') {
|
||||
height = rowHeight(i, mockCellProps);
|
||||
} else {
|
||||
height = DEFAULT_ROW_HEIGHT;
|
||||
}
|
||||
scrollTop += height;
|
||||
}
|
||||
return scrollTop;
|
||||
},
|
||||
[enableHeader, rowHeight],
|
||||
[
|
||||
enableHeader,
|
||||
rowHeight,
|
||||
size,
|
||||
DEFAULT_ROW_HEIGHT,
|
||||
cellPadding,
|
||||
parsedColumns,
|
||||
enableAlternateRowColors,
|
||||
enableExpansion,
|
||||
enableHorizontalBorders,
|
||||
enableRowHoverHighlight,
|
||||
enableSelection,
|
||||
enableVerticalBorders,
|
||||
itemType,
|
||||
data,
|
||||
],
|
||||
);
|
||||
|
||||
const scrollToTableIndex = useCallback(
|
||||
@@ -1038,6 +1083,125 @@ export const ItemTableList = ({
|
||||
[data, enableSelection, internalState, itemType],
|
||||
);
|
||||
|
||||
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();
|
||||
let currentIndex = -1;
|
||||
|
||||
if (selected.length > 0) {
|
||||
const lastSelected = selected[selected.length - 1];
|
||||
currentIndex = data.findIndex(
|
||||
(d: any) => d && typeof d === 'object' && 'id' in d && d.id === lastSelected.id,
|
||||
);
|
||||
}
|
||||
|
||||
let newIndex = 0;
|
||||
if (currentIndex !== -1) {
|
||||
newIndex =
|
||||
e.key === 'ArrowDown'
|
||||
? Math.min(currentIndex + 1, data.length - 1)
|
||||
: Math.max(currentIndex - 1, 0);
|
||||
}
|
||||
|
||||
const newItem: any = data[newIndex];
|
||||
if (!newItem) return;
|
||||
|
||||
// Handle Shift + Arrow for incremental range selection (matches shift+click behavior)
|
||||
if (e.shiftKey) {
|
||||
const selectedItems = internalState.getSelected();
|
||||
const lastSelectedItem = selectedItems[selectedItems.length - 1];
|
||||
|
||||
if (lastSelectedItem) {
|
||||
// Find the indices of the last selected item and new item
|
||||
const lastIndex = data.findIndex(
|
||||
(d: any) =>
|
||||
d && typeof d === 'object' && 'id' in d && d.id === lastSelectedItem.id,
|
||||
);
|
||||
|
||||
if (lastIndex !== -1 && newIndex !== -1) {
|
||||
// Create range selection from last selected to new position
|
||||
const startIndex = Math.min(lastIndex, newIndex);
|
||||
const stopIndex = Math.max(lastIndex, newIndex);
|
||||
|
||||
const rangeItems: ItemListItem[] = [];
|
||||
for (let i = startIndex; i <= stopIndex; i++) {
|
||||
const rangeItem = data[i];
|
||||
if (
|
||||
rangeItem &&
|
||||
typeof rangeItem === 'object' &&
|
||||
'id' in rangeItem &&
|
||||
'serverId' in rangeItem
|
||||
) {
|
||||
rangeItems.push({
|
||||
_serverId: (rangeItem as any).serverId,
|
||||
id: (rangeItem as any).id,
|
||||
itemType,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add range items to selection (matching shift+click behavior)
|
||||
const currentSelected = internalState.getSelected();
|
||||
const newSelected = [...currentSelected];
|
||||
rangeItems.forEach((rangeItem) => {
|
||||
if (!newSelected.some((selected) => selected.id === rangeItem.id)) {
|
||||
newSelected.push(rangeItem);
|
||||
}
|
||||
});
|
||||
|
||||
// Ensure the last item in selection is the item at newIndex for incremental extension
|
||||
const newItemListItem: ItemListItem = {
|
||||
_serverId: newItem.serverId,
|
||||
id: newItem.id,
|
||||
itemType,
|
||||
};
|
||||
// Remove the new item from its current position if it exists
|
||||
const filteredSelected = newSelected.filter(
|
||||
(item) => item.id !== newItemListItem.id,
|
||||
);
|
||||
// Add it at the end so it becomes the last selected item
|
||||
filteredSelected.push(newItemListItem);
|
||||
internalState.setSelected(filteredSelected);
|
||||
}
|
||||
} else {
|
||||
// No previous selection, just select the new item
|
||||
internalState.setSelected([
|
||||
{
|
||||
_serverId: newItem.serverId,
|
||||
id: newItem.id,
|
||||
itemType,
|
||||
},
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
// Without Shift: select only the new item
|
||||
internalState.setSelected([
|
||||
{
|
||||
_serverId: newItem.serverId,
|
||||
id: newItem.id,
|
||||
itemType,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
const offset = calculateScrollTopForIndex(newIndex);
|
||||
scrollToTableOffset(offset);
|
||||
},
|
||||
[
|
||||
data,
|
||||
enableSelection,
|
||||
internalState,
|
||||
itemType,
|
||||
calculateScrollTopForIndex,
|
||||
scrollToTableOffset,
|
||||
],
|
||||
);
|
||||
|
||||
const isInitialScrollPositionSet = useRef<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1088,7 +1252,13 @@ export const ItemTableList = ({
|
||||
const controls = useDefaultItemListControls();
|
||||
|
||||
return (
|
||||
<div className={styles.itemTableListContainer}>
|
||||
<div
|
||||
className={styles.itemTableListContainer}
|
||||
onKeyDown={handleKeyDown}
|
||||
onMouseDown={(e) => (e.currentTarget as HTMLDivElement).focus()}
|
||||
ref={containerFocusRef}
|
||||
tabIndex={0}
|
||||
>
|
||||
<VirtualizedTableGrid
|
||||
calculatedColumnWidths={calculatedColumnWidths}
|
||||
CellComponent={CellComponent}
|
||||
|
||||
Reference in New Issue
Block a user