add table item selection

This commit is contained in:
jeffvli
2025-10-23 00:19:06 -07:00
parent 08da9591da
commit 74d5f2c61f
12 changed files with 235 additions and 9 deletions
@@ -1,3 +1,5 @@
import { MouseEvent } from 'react';
import {
ItemListItem,
ItemListStateActions,
@@ -10,7 +12,26 @@ const handleItemClick = (
itemType: LibraryItem,
internalState: ItemListStateActions,
) => {
console.log('handleItemClick', item, itemType, internalState);
if (!item) {
return;
}
const itemListItem: ItemListItem = {
id: item.id,
itemType,
serverId: item.serverId,
};
// Regular click - deselect all others and select only this item
// If this item is already the only selected item, deselect it
const selectedItems = internalState.getSelected();
const isOnlySelected = selectedItems.length === 1 && selectedItems[0].id === item.id;
if (isOnlySelected) {
internalState.clearSelected();
} else {
internalState.setSelected([itemListItem]);
}
};
const handleItemDoubleClick = (
@@ -13,6 +13,7 @@
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
color: var(--theme-colors-foreground-muted);
user-select: none;
}
.artists-container.compact {
@@ -3,8 +3,8 @@
overflow: hidden;
-webkit-line-clamp: 2;
color: var(--theme-colors-foreground-muted);
user-select: none;
-webkit-box-orient: vertical;
user-select: none;
}
.genres-container.compact {
@@ -36,6 +36,7 @@ export const TitleColumn = (props: ItemTableListInnerColumn) => {
[styles.large]: props.size === 'large',
[styles.nameContainer]: true,
})}
isNoSelect
{...titleLinkProps}
>
{row}
@@ -82,7 +82,6 @@
}
}
.container.data-row.row-hover-highlight-enabled:hover::before,
.container.data-row.row-hover-highlight-enabled.row-hovered::before {
position: absolute;
top: 0;
@@ -96,11 +95,35 @@
opacity: 0.7;
}
.container.data-row.row-selected::before {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 1;
pointer-events: none;
content: '';
opacity: 0.7;
@mixin dark {
background-color: lighten(var(--theme-colors-surface), 5%);
}
@mixin light {
background-color: var(--theme-colors-surface);
}
}
.container.data-row > * {
position: relative;
z-index: 2;
}
.container.data-row {
cursor: pointer;
}
.header-container {
background: none;
}
@@ -47,8 +47,13 @@ export const ItemTableListColumn = (props: ItemTableListColumn) => {
const isHeaderEnabled = !!props.enableHeader;
const controls: ItemControls = {
onClick: (item, itemType) =>
itemListControls.handleItemClick(item, itemType, props.internalState),
onClick: (item, itemType, event) => {
if (props.onRowClick && item) {
props.onRowClick(item, event);
} else {
itemListControls.handleItemClick(item, itemType, props.internalState);
}
},
onDoubleClick: (item, itemType) =>
itemListControls.handleItemDoubleClick(item, itemType, props.internalState),
onFavorite: (item, itemType) =>
@@ -158,8 +163,13 @@ export const TableColumnTextContainer = (
},
) => {
const containerRef = useRef<HTMLDivElement>(null);
const isDataRow = props.enableHeader && props.rowIndex > 0;
const dataIndex = isDataRow ? props.rowIndex - 1 : props.rowIndex;
const isDataRow = props.enableHeader ? props.rowIndex > 0 : true;
const dataIndex = props.enableHeader ? props.rowIndex - 1 : props.rowIndex;
const item = isDataRow ? props.data[props.rowIndex] : null;
const isSelected =
item && typeof item === 'object' && 'id' in item
? props.internalState.isSelected((item as any).id)
: false;
useEffect(() => {
if (!isDataRow || !containerRef.current) return;
@@ -188,6 +198,22 @@ export const TableColumnTextContainer = (
};
}, [isDataRow, props.rowIndex, props.enableRowHoverHighlight]);
const handleCellClick = (event: React.MouseEvent<HTMLDivElement>) => {
// Don't trigger row selection if clicking on interactive elements
const target = event.target as HTMLElement;
const isInteractiveElement = target.closest(
'button, a, input, select, textarea, [role="button"]',
);
if (isInteractiveElement) {
return;
}
if (isDataRow && item && props.enableSelection) {
props.controls.onClick?.(item as any, props.itemType, event);
}
};
return (
<div
className={clsx(styles.container, props.containerClassName, {
@@ -207,11 +233,13 @@ export const TableColumnTextContainer = (
[styles.paddingXs]: props.cellPadding === 'xs',
[styles.right]: props.columns[props.columnIndex].align === 'end',
[styles.rowHoverHighlightEnabled]: isDataRow && props.enableRowHoverHighlight,
[styles.rowSelected]: isDataRow && isSelected,
[styles.withHorizontalBorder]:
props.enableHorizontalBorders && props.enableHeader && props.rowIndex > 0,
[styles.withVerticalBorder]: props.enableVerticalBorders,
})}
data-row-index={isDataRow ? props.rowIndex : undefined}
onClick={handleCellClick}
ref={containerRef}
style={props.style}
>
@@ -239,8 +267,13 @@ export const TableColumnContainer = (
},
) => {
const containerRef = useRef<HTMLDivElement>(null);
const isDataRow = props.enableHeader && props.rowIndex > 0;
const dataIndex = isDataRow ? props.rowIndex - 1 : props.rowIndex;
const isDataRow = props.enableHeader ? props.rowIndex > 0 : true;
const dataIndex = props.enableHeader ? props.rowIndex - 1 : props.rowIndex;
const item = isDataRow ? props.data[props.rowIndex] : null;
const isSelected =
item && typeof item === 'object' && 'id' in item
? props.internalState.isSelected((item as any).id)
: false;
useEffect(() => {
if (!isDataRow || !containerRef.current) return;
@@ -269,6 +302,22 @@ export const TableColumnContainer = (
};
}, [isDataRow, props.rowIndex, props.enableRowHoverHighlight]);
const handleCellClick = (event: React.MouseEvent<HTMLDivElement>) => {
// Don't trigger row selection if clicking on interactive elements
const target = event.target as HTMLElement;
const isInteractiveElement = target.closest(
'button, a, input, select, textarea, [role="button"]',
);
if (isInteractiveElement) {
return;
}
if (isDataRow && item && props.enableSelection) {
props.controls.onClick?.(item as any, props.itemType, event);
}
};
return (
<div
className={clsx(styles.container, props.className, {
@@ -288,11 +337,13 @@ export const TableColumnContainer = (
[styles.paddingXs]: props.cellPadding === 'xs',
[styles.right]: props.columns[props.columnIndex].align === 'end',
[styles.rowHoverHighlightEnabled]: isDataRow && props.enableRowHoverHighlight,
[styles.rowSelected]: isDataRow && isSelected,
[styles.withHorizontalBorder]:
props.enableHorizontalBorders && props.enableHeader && props.rowIndex > 0,
[styles.withVerticalBorder]: props.enableVerticalBorders,
})}
data-row-index={isDataRow ? props.rowIndex : undefined}
onClick={handleCellClick}
ref={containerRef}
style={{ ...props.containerStyle, ...props.style }}
>
@@ -21,6 +21,7 @@ import styles from './item-table-list.module.css';
import { ExpandedListContainer } from '/@/renderer/components/item-list/expanded-list-container';
import { ExpandedListItem } from '/@/renderer/components/item-list/expanded-list-item';
import {
ItemListItem,
ItemListStateActions,
useItemListState,
} from '/@/renderer/components/item-list/helpers/item-list-state';
@@ -46,6 +47,7 @@ interface VirtualizedTableGridProps {
itemType: LibraryItem;
mergedRowRef: React.Ref<HTMLDivElement>;
onCellsRendered?: GridProps<TableItemProps>['onCellsRendered'];
onRowClick?: (item: any, event: React.MouseEvent<HTMLDivElement>) => void;
parsedColumns: ReturnType<typeof parseTableColumns>;
pinnedLeftColumnCount: number;
pinnedLeftColumnRef: React.RefObject<HTMLDivElement>;
@@ -79,6 +81,7 @@ const VirtualizedTableGrid = React.memo(
itemType,
mergedRowRef,
onCellsRendered,
onRowClick,
parsedColumns,
pinnedLeftColumnCount,
pinnedLeftColumnRef,
@@ -112,6 +115,7 @@ const VirtualizedTableGrid = React.memo(
getRowHeight,
internalState,
itemType,
onRowClick,
size,
}),
[
@@ -128,6 +132,7 @@ const VirtualizedTableGrid = React.memo(
getRowHeight,
internalState,
itemType,
onRowClick,
size,
],
);
@@ -390,6 +395,7 @@ export interface TableItemProps {
getRowHeight: (index: number, cellProps: TableItemProps) => number;
internalState: ItemListStateActions;
itemType: ItemTableListProps['itemType'];
onRowClick?: (item: any, event: React.MouseEvent<HTMLDivElement>) => void;
size?: ItemTableListProps['size'];
}
@@ -894,6 +900,119 @@ export const ItemTableList = ({
const hasExpanded = internalState.hasExpanded();
const handleRowClick = useCallback(
(item: any, event: React.MouseEvent<HTMLDivElement>) => {
if (!enableSelection || !item) {
return;
}
const itemListItem: ItemListItem = {
id: item.id,
itemType,
serverId: item.serverId,
};
// Check if ctrl/cmd key is held for multi-selection
if (event.ctrlKey || event.metaKey) {
const isCurrentlySelected = internalState.isSelected(item.id);
if (isCurrentlySelected) {
// Remove this item from selection
const currentSelected = internalState.getSelected();
const filteredSelected = currentSelected.filter(
(selectedItem) => selectedItem.id !== item.id,
);
internalState.setSelected(filteredSelected);
} else {
// Add this item to selection
const currentSelected = internalState.getSelected();
const newSelected = [...currentSelected, itemListItem];
internalState.setSelected(newSelected);
}
}
// Check if shift key is held for range selection
else if (event.shiftKey) {
const selectedItems = internalState.getSelected();
const lastSelectedItem = selectedItems[selectedItems.length - 1];
if (lastSelectedItem) {
// Find the indices of the last selected item and current item
const lastIndex = data.findIndex(
(d) =>
d && typeof d === 'object' && 'id' in d && d.id === lastSelectedItem.id,
);
const currentIndex = data.findIndex(
(d) => d && typeof d === 'object' && 'id' in d && d.id === item.id,
);
if (lastIndex !== -1 && currentIndex !== -1) {
// Create range selection
const startIndex = Math.min(lastIndex, currentIndex);
const endIndex = Math.max(lastIndex, currentIndex);
const rangeItems: ItemListItem[] = [];
for (let i = startIndex; i <= endIndex; i++) {
const rangeItem = data[i];
if (
rangeItem &&
typeof rangeItem === 'object' &&
'id' in rangeItem &&
'serverId' in rangeItem
) {
rangeItems.push({
id: (rangeItem as any).id,
itemType,
serverId: (rangeItem as any).serverId,
});
}
}
// Toggle selection for the range
const isCurrentlySelected = internalState.isSelected(item.id);
if (isCurrentlySelected) {
// Deselect the range
const currentSelected = internalState.getSelected();
const filteredSelected = currentSelected.filter(
(selectedItem) =>
!rangeItems.some(
(rangeItem) => rangeItem.id === selectedItem.id,
),
);
internalState.setSelected(filteredSelected);
} else {
// Select the range
const currentSelected = internalState.getSelected();
const newSelected = [...currentSelected];
rangeItems.forEach((rangeItem) => {
if (!newSelected.some((selected) => selected.id === rangeItem.id)) {
newSelected.push(rangeItem);
}
});
internalState.setSelected(newSelected);
}
}
} else {
// No previous selection, just toggle this item
internalState.toggleSelected(itemListItem);
}
} else {
// Regular click - deselect all others and select only this item
// If this item is already the only selected item, deselect it
const selectedItems = internalState.getSelected();
const isOnlySelected =
selectedItems.length === 1 && selectedItems[0].id === item.id;
if (isOnlySelected) {
internalState.clearSelected();
} else {
internalState.setSelected([itemListItem]);
}
}
},
[data, enableSelection, internalState, itemType],
);
const handleOnCellsRendered = useCallback(
(cells: {
columnStartIndex: number;
@@ -1007,6 +1126,7 @@ export const ItemTableList = ({
itemType={itemType}
mergedRowRef={mergedRowRef}
onCellsRendered={handleOnCellsRendered}
onRowClick={handleRowClick}
parsedColumns={parsedColumns}
pinnedLeftColumnCount={pinnedLeftColumnCount}
pinnedLeftColumnRef={pinnedLeftColumnRef}
@@ -78,6 +78,7 @@ export interface ItemListTableComponentProps<TQuery> extends ItemListComponentPr
enableAlternateRowColors?: boolean;
enableHorizontalBorders?: boolean;
enableRowHoverHighlight?: boolean;
enableSelection?: boolean;
enableVerticalBorders?: boolean;
size?: 'compact' | 'default';
}
@@ -25,6 +25,7 @@ export const AlbumListInfiniteTable = forwardRef<any, AlbumListInfiniteTableProp
enableAlternateRowColors = false,
enableHorizontalBorders = false,
enableRowHoverHighlight = true,
enableSelection = true,
enableVerticalBorders = false,
itemsPerPage = 100,
query = {
@@ -66,6 +67,7 @@ export const AlbumListInfiniteTable = forwardRef<any, AlbumListInfiniteTableProp
enableAlternateRowColors={enableAlternateRowColors}
enableHorizontalBorders={enableHorizontalBorders}
enableRowHoverHighlight={enableRowHoverHighlight}
enableSelection={enableSelection}
enableVerticalBorders={enableVerticalBorders}
initialTop={{
to: scrollOffset ?? 0,
@@ -26,6 +26,7 @@ export const AlbumListPaginatedTable = forwardRef<any, AlbumListPaginatedTablePr
enableAlternateRowColors = false,
enableHorizontalBorders = false,
enableRowHoverHighlight = true,
enableSelection = true,
enableVerticalBorders = false,
itemsPerPage = 100,
query = {
@@ -76,6 +77,7 @@ export const AlbumListPaginatedTable = forwardRef<any, AlbumListPaginatedTablePr
enableAlternateRowColors={enableAlternateRowColors}
enableHorizontalBorders={enableHorizontalBorders}
enableRowHoverHighlight={enableRowHoverHighlight}
enableSelection={enableSelection}
enableVerticalBorders={enableVerticalBorders}
initialTop={{
to: scrollOffset ?? 0,
@@ -20,6 +20,7 @@ export const SongListInfiniteTable = forwardRef<any, SongListInfiniteTableProps>
enableAlternateRowColors = false,
enableHorizontalBorders = false,
enableRowHoverHighlight = true,
enableSelection = true,
enableVerticalBorders = false,
itemsPerPage = 100,
query = {
@@ -61,6 +62,7 @@ export const SongListInfiniteTable = forwardRef<any, SongListInfiniteTableProps>
enableAlternateRowColors={enableAlternateRowColors}
enableHorizontalBorders={enableHorizontalBorders}
enableRowHoverHighlight={enableRowHoverHighlight}
enableSelection={enableSelection}
enableVerticalBorders={enableVerticalBorders}
initialTop={{
to: scrollOffset ?? 0,
@@ -21,6 +21,7 @@ export const SongListPaginatedTable = forwardRef<any, SongListPaginatedTableProp
enableAlternateRowColors = false,
enableHorizontalBorders = false,
enableRowHoverHighlight = true,
enableSelection = true,
enableVerticalBorders = false,
itemsPerPage = 100,
query = {
@@ -71,6 +72,7 @@ export const SongListPaginatedTable = forwardRef<any, SongListPaginatedTableProp
enableAlternateRowColors={enableAlternateRowColors}
enableHorizontalBorders={enableHorizontalBorders}
enableRowHoverHighlight={enableRowHoverHighlight}
enableSelection={enableSelection}
enableVerticalBorders={enableVerticalBorders}
initialTop={{
to: scrollOffset ?? 0,