implement list multiselection

This commit is contained in:
jeffvli
2025-11-08 15:35:10 -08:00
parent 7a4326f98d
commit a87d5ef8d8
6 changed files with 217 additions and 20 deletions
@@ -1,13 +1,26 @@
.container { .container {
position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: 100%; width: 100%;
padding: var(--theme-spacing-md); padding: var(--theme-spacing-md);
overflow: hidden; overflow: hidden;
user-select: none;
background-color: var(--theme-colors-surface); background-color: var(--theme-colors-surface);
border-radius: var(--theme-radius-md); border-radius: var(--theme-radius-md);
} }
.container.previewed {
outline: 2px dashed var(--theme-colors-primary);
outline-offset: 2px;
opacity: 0.7;
}
.container.selected {
outline: 2px solid var(--theme-colors-primary);
outline-offset: 2px;
}
.image-container { .image-container {
position: relative; position: relative;
width: 100%; width: 100%;
@@ -105,12 +105,17 @@ const CompactItemCard = ({
controls, controls,
data, data,
imageUrl, imageUrl,
internalState,
isRound, isRound,
itemType, itemType,
rows, rows,
withControls, withControls,
}: ItemCardDerivativeProps) => { }: ItemCardDerivativeProps) => {
const [showControls, setShowControls] = useState(false); const [showControls, setShowControls] = useState(false);
const isSelected =
data && internalState && typeof data === 'object' && 'id' in data
? internalState.isSelected((data as any).id)
: false;
if (data) { if (data) {
const handleMouseEnter = () => { const handleMouseEnter = () => {
@@ -126,11 +131,34 @@ const CompactItemCard = ({
}; };
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => { const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
// controls?.onClick?.(data, itemType, e); if (!data || !controls || !internalState) {
return;
}
// Don't trigger selection if clicking on interactive elements
const target = e.target as HTMLElement;
const isInteractiveElement = target.closest(
'button, a, input, select, textarea, [role="button"]',
);
if (isInteractiveElement) {
return;
}
controls.onClick?.({
event: e,
internalState,
item: data as any,
itemType,
});
}; };
return ( return (
<div className={clsx(styles.container, styles.compact)}> <div
className={clsx(styles.container, styles.compact, {
[styles.selected]: isSelected,
})}
>
<div <div
className={clsx(styles.imageContainer, { [styles.isRound]: isRound })} className={clsx(styles.imageContainer, { [styles.isRound]: isRound })}
onClick={handleClick} onClick={handleClick}
@@ -181,12 +209,17 @@ const DefaultItemCard = ({
controls, controls,
data, data,
imageUrl, imageUrl,
internalState,
isRound, isRound,
itemType, itemType,
rows, rows,
withControls, withControls,
}: ItemCardDerivativeProps) => { }: ItemCardDerivativeProps) => {
const [showControls, setShowControls] = useState(false); const [showControls, setShowControls] = useState(false);
const isSelected =
data && internalState && typeof data === 'object' && 'id' in data
? internalState.isSelected((data as any).id)
: false;
if (data) { if (data) {
const handleMouseEnter = () => { const handleMouseEnter = () => {
@@ -202,11 +235,34 @@ const DefaultItemCard = ({
}; };
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => { const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
// controls?.onClick?.(data, itemType, e); if (!data || !controls || !internalState) {
return;
}
// Don't trigger selection if clicking on interactive elements
const target = e.target as HTMLElement;
const isInteractiveElement = target.closest(
'button, a, input, select, textarea, [role="button"]',
);
if (isInteractiveElement) {
return;
}
controls.onClick?.({
event: e,
internalState,
item: data as any,
itemType,
});
}; };
return ( return (
<div className={clsx(styles.container)}> <div
className={clsx(styles.container, {
[styles.selected]: isSelected,
})}
>
<div <div
className={clsx(styles.imageContainer, { [styles.isRound]: isRound })} className={clsx(styles.imageContainer, { [styles.isRound]: isRound })}
onClick={handleClick} onClick={handleClick}
@@ -264,6 +320,10 @@ const PosterItemCard = ({
withControls, withControls,
}: ItemCardDerivativeProps) => { }: ItemCardDerivativeProps) => {
const [showControls, setShowControls] = useState(false); const [showControls, setShowControls] = useState(false);
const isSelected =
data && internalState && typeof data === 'object' && 'id' in data
? internalState.isSelected((data as any).id)
: false;
if (data) { if (data) {
const handleMouseEnter = () => { const handleMouseEnter = () => {
@@ -279,11 +339,34 @@ const PosterItemCard = ({
}; };
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => { const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
// controls?.onClick?.(data, itemType, e); if (!data || !controls || !internalState) {
return;
}
// Don't trigger selection if clicking on interactive elements
const target = e.target as HTMLElement;
const isInteractiveElement = target.closest(
'button, a, input, select, textarea, [role="button"]',
);
if (isInteractiveElement) {
return;
}
controls.onClick?.({
event: e,
internalState,
item: data as any,
itemType,
});
}; };
return ( return (
<div className={clsx(styles.container, styles.poster)}> <div
className={clsx(styles.container, styles.poster, {
[styles.selected]: isSelected,
})}
>
<div <div
className={clsx(styles.imageContainer, { [styles.isRound]: isRound })} className={clsx(styles.imageContainer, { [styles.isRound]: isRound })}
onClick={handleClick} onClick={handleClick}
@@ -10,8 +10,8 @@ export const useDefaultItemListControls = () => {
const controls: ItemControls = useMemo(() => { const controls: ItemControls = useMemo(() => {
return { return {
onClick: ({ internalState, item, itemType }: DefaultItemControlProps) => { onClick: ({ event, internalState, item, itemType }: DefaultItemControlProps) => {
if (!item) { if (!item || !internalState || !event) {
return; return;
} }
@@ -21,6 +21,78 @@ export const useDefaultItemListControls = () => {
itemType, itemType,
}; };
// 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) {
// Get the data array from internalState
const data = internalState.getData();
// Filter out null/undefined values (e.g., header row)
const validData = data.filter(
(d) => d && typeof d === 'object' && 'id' in d,
);
// Find the indices of the last selected item and current item
const lastIndex = internalState.findItemIndex(lastSelectedItem.id);
const currentIndex = internalState.findItemIndex(item.id);
if (lastIndex !== -1 && currentIndex !== -1) {
// Create range selection - select ALL items in the range
const startIndex = Math.min(lastIndex, currentIndex);
const stopIndex = Math.max(lastIndex, currentIndex);
const rangeItems: ItemListItem[] = [];
for (let i = startIndex; i <= stopIndex; i++) {
const rangeItem = validData[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,
});
}
}
// Merge with existing selection, avoiding duplicates
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 select this item
internalState.setSelected([itemListItem]);
}
} else {
// Regular click - deselect all others and select only this item // Regular click - deselect all others and select only this item
// If this item is already the only selected item, deselect it // If this item is already the only selected item, deselect it
const selectedItems = internalState.getSelected(); const selectedItems = internalState.getSelected();
@@ -32,6 +104,7 @@ export const useDefaultItemListControls = () => {
} else { } else {
internalState.setSelected([itemListItem]); internalState.setSelected([itemListItem]);
} }
}
}, },
onDoubleClick: ({ internalState, item, itemType }: DefaultItemControlProps) => { onDoubleClick: ({ internalState, item, itemType }: DefaultItemControlProps) => {
@@ -30,6 +30,8 @@ export interface ItemListStateActions {
clearAll: () => void; clearAll: () => void;
clearExpanded: () => void; clearExpanded: () => void;
clearSelected: () => void; clearSelected: () => void;
findItemIndex: (itemId: string) => number;
getData: () => unknown[];
getExpanded: () => ItemListItem[]; getExpanded: () => ItemListItem[];
getExpandedIds: () => string[]; getExpandedIds: () => string[];
getSelected: () => ItemListItem[]; getSelected: () => ItemListItem[];
@@ -168,7 +170,7 @@ export const initialItemListState: ItemListState = {
version: 0, version: 0,
}; };
export const useItemListState = (): ItemListStateActions => { export const useItemListState = (getDataFn?: () => unknown[]): ItemListStateActions => {
const [state, dispatch] = useReducer(itemListReducer, initialItemListState); const [state, dispatch] = useReducer(itemListReducer, initialItemListState);
const setExpanded = useCallback((items: ItemListItem[]) => { const setExpanded = useCallback((items: ItemListItem[]) => {
@@ -241,11 +243,27 @@ export const useItemListState = (): ItemListStateActions => {
return itemGridSelectors.hasAnySelected(state); return itemGridSelectors.hasAnySelected(state);
}, [state]); }, [state]);
const getData = useCallback(() => {
return getDataFn ? getDataFn() : [];
}, [getDataFn]);
const findItemIndex = useCallback(
(itemId: string) => {
const data = getDataFn ? getDataFn() : [];
// Filter out null/undefined values (e.g., header row)
const validData = data.filter((d) => d && typeof d === 'object' && 'id' in d);
return validData.findIndex((d) => (d as any).id === itemId);
},
[getDataFn],
);
return useMemo( return useMemo(
() => ({ () => ({
clearAll, clearAll,
clearExpanded, clearExpanded,
clearSelected, clearSelected,
findItemIndex,
getData,
getExpanded, getExpanded,
getExpandedIds, getExpandedIds,
getSelected, getSelected,
@@ -264,6 +282,8 @@ export const useItemListState = (): ItemListStateActions => {
clearAll, clearAll,
clearExpanded, clearExpanded,
clearSelected, clearSelected,
findItemIndex,
getData,
getExpanded, getExpanded,
getExpandedIds, getExpandedIds,
getSelected, getSelected,
@@ -273,7 +273,11 @@ export const ItemGridList = ({
const { ref: containerRef, width: containerWidth } = useElementSize(); const { ref: containerRef, width: containerWidth } = useElementSize();
const mergedContainerRef = useMergedRef(containerRef, rootRef); const mergedContainerRef = useMergedRef(containerRef, rootRef);
const internalState = useItemListState(); const getDataFn = useCallback(() => {
return data;
}, [data]);
const internalState = useItemListState(getDataFn);
const [initialize] = useOverlayScrollbars({ const [initialize] = useOverlayScrollbars({
defer: true, defer: true,
@@ -871,7 +871,7 @@ export const ItemTableList = ({
} }
return undefined; return undefined;
}, []); }, [pinnedLeftColumnCount, pinnedRightColumnCount]);
// Handle left and right shadow visibility based on horizontal scroll // Handle left and right shadow visibility based on horizontal scroll
useEffect(() => { useEffect(() => {
@@ -917,7 +917,11 @@ export const ItemTableList = ({
[enableHeader, headerHeight, rowHeight, pinnedRowCount, size], [enableHeader, headerHeight, rowHeight, pinnedRowCount, size],
); );
const internalState = useItemListState(); const getDataFn = useCallback(() => {
return enableHeader ? [null, ...data] : data;
}, [data, enableHeader]);
const internalState = useItemListState(getDataFn);
const hasExpanded = internalState.hasExpanded(); const hasExpanded = internalState.hasExpanded();