From 7d4a17e89cc8003cb84d52f75e1af0dcdd535dd3 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Sat, 8 Nov 2025 17:29:20 -0800 Subject: [PATCH] add keyboard navigation and selection to lists --- .../item-grid-list/item-grid-list.module.css | 2 + .../item-grid-list/item-grid-list.tsx | 249 +++++++++++++++++- .../item-table-list/item-table-list.tsx | 176 ++++++++++++- 3 files changed, 423 insertions(+), 4 deletions(-) diff --git a/src/renderer/components/item-list/item-grid-list/item-grid-list.module.css b/src/renderer/components/item-list/item-grid-list/item-grid-list.module.css index a6f92fd23..6f673022b 100644 --- a/src/renderer/components/item-list/item-grid-list/item-grid-list.module.css +++ b/src/renderer/components/item-list/item-grid-list/item-grid-list.module.css @@ -3,6 +3,8 @@ flex-direction: column !important; width: 100%; height: 100%; + outline: none; + border: none; } .auto-sizer-container { diff --git a/src/renderer/components/item-list/item-grid-list/item-grid-list.tsx b/src/renderer/components/item-list/item-grid-list/item-grid-list.tsx index f2dd8fada..47c09b18a 100644 --- a/src/renderer/components/item-list/item-grid-list/item-grid-list.tsx +++ b/src/renderer/components/item-list/item-grid-list/item-grid-list.tsx @@ -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>(null); const { ref: containerRef, width: containerWidth } = useElementSize(); - const mergedContainerRef = useMergedRef(containerRef, rootRef); + const containerFocusRef = useRef(null); + const handleRef = useRef(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) => { + 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 (
(e.currentTarget as HTMLDivElement).focus()} ref={mergedContainerRef} + tabIndex={0} > (null); + const containerFocusRef = useRef(null); const onScrollEndRef = useRef(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) => { + 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(false); useEffect(() => { @@ -1088,7 +1252,13 @@ export const ItemTableList = ({ const controls = useDefaultItemListControls(); return ( -
+
(e.currentTarget as HTMLDivElement).focus()} + ref={containerFocusRef} + tabIndex={0} + >