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 {
position: relative;
display: flex;
flex-direction: column;
width: 100%;
padding: var(--theme-spacing-md);
overflow: hidden;
user-select: none;
background-color: var(--theme-colors-surface);
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 {
position: relative;
width: 100%;
@@ -105,12 +105,17 @@ const CompactItemCard = ({
controls,
data,
imageUrl,
internalState,
isRound,
itemType,
rows,
withControls,
}: ItemCardDerivativeProps) => {
const [showControls, setShowControls] = useState(false);
const isSelected =
data && internalState && typeof data === 'object' && 'id' in data
? internalState.isSelected((data as any).id)
: false;
if (data) {
const handleMouseEnter = () => {
@@ -126,11 +131,34 @@ const CompactItemCard = ({
};
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 (
<div className={clsx(styles.container, styles.compact)}>
<div
className={clsx(styles.container, styles.compact, {
[styles.selected]: isSelected,
})}
>
<div
className={clsx(styles.imageContainer, { [styles.isRound]: isRound })}
onClick={handleClick}
@@ -181,12 +209,17 @@ const DefaultItemCard = ({
controls,
data,
imageUrl,
internalState,
isRound,
itemType,
rows,
withControls,
}: ItemCardDerivativeProps) => {
const [showControls, setShowControls] = useState(false);
const isSelected =
data && internalState && typeof data === 'object' && 'id' in data
? internalState.isSelected((data as any).id)
: false;
if (data) {
const handleMouseEnter = () => {
@@ -202,11 +235,34 @@ const DefaultItemCard = ({
};
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 (
<div className={clsx(styles.container)}>
<div
className={clsx(styles.container, {
[styles.selected]: isSelected,
})}
>
<div
className={clsx(styles.imageContainer, { [styles.isRound]: isRound })}
onClick={handleClick}
@@ -264,6 +320,10 @@ const PosterItemCard = ({
withControls,
}: ItemCardDerivativeProps) => {
const [showControls, setShowControls] = useState(false);
const isSelected =
data && internalState && typeof data === 'object' && 'id' in data
? internalState.isSelected((data as any).id)
: false;
if (data) {
const handleMouseEnter = () => {
@@ -279,11 +339,34 @@ const PosterItemCard = ({
};
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 (
<div className={clsx(styles.container, styles.poster)}>
<div
className={clsx(styles.container, styles.poster, {
[styles.selected]: isSelected,
})}
>
<div
className={clsx(styles.imageContainer, { [styles.isRound]: isRound })}
onClick={handleClick}
@@ -10,8 +10,8 @@ export const useDefaultItemListControls = () => {
const controls: ItemControls = useMemo(() => {
return {
onClick: ({ internalState, item, itemType }: DefaultItemControlProps) => {
if (!item) {
onClick: ({ event, internalState, item, itemType }: DefaultItemControlProps) => {
if (!item || !internalState || !event) {
return;
}
@@ -21,16 +21,89 @@ export const useDefaultItemListControls = () => {
itemType,
};
// 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;
// Check if ctrl/cmd key is held for multi-selection
if (event.ctrlKey || event.metaKey) {
const isCurrentlySelected = internalState.isSelected(item.id);
if (isOnlySelected) {
internalState.clearSelected();
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 {
internalState.setSelected([itemListItem]);
// 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]);
}
}
},
@@ -30,6 +30,8 @@ export interface ItemListStateActions {
clearAll: () => void;
clearExpanded: () => void;
clearSelected: () => void;
findItemIndex: (itemId: string) => number;
getData: () => unknown[];
getExpanded: () => ItemListItem[];
getExpandedIds: () => string[];
getSelected: () => ItemListItem[];
@@ -168,7 +170,7 @@ export const initialItemListState: ItemListState = {
version: 0,
};
export const useItemListState = (): ItemListStateActions => {
export const useItemListState = (getDataFn?: () => unknown[]): ItemListStateActions => {
const [state, dispatch] = useReducer(itemListReducer, initialItemListState);
const setExpanded = useCallback((items: ItemListItem[]) => {
@@ -241,11 +243,27 @@ export const useItemListState = (): ItemListStateActions => {
return itemGridSelectors.hasAnySelected(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(
() => ({
clearAll,
clearExpanded,
clearSelected,
findItemIndex,
getData,
getExpanded,
getExpandedIds,
getSelected,
@@ -264,6 +282,8 @@ export const useItemListState = (): ItemListStateActions => {
clearAll,
clearExpanded,
clearSelected,
findItemIndex,
getData,
getExpanded,
getExpandedIds,
getSelected,
@@ -273,7 +273,11 @@ export const ItemGridList = ({
const { ref: containerRef, width: containerWidth } = useElementSize();
const mergedContainerRef = useMergedRef(containerRef, rootRef);
const internalState = useItemListState();
const getDataFn = useCallback(() => {
return data;
}, [data]);
const internalState = useItemListState(getDataFn);
const [initialize] = useOverlayScrollbars({
defer: true,
@@ -871,7 +871,7 @@ export const ItemTableList = ({
}
return undefined;
}, []);
}, [pinnedLeftColumnCount, pinnedRightColumnCount]);
// Handle left and right shadow visibility based on horizontal scroll
useEffect(() => {
@@ -917,7 +917,11 @@ export const ItemTableList = ({
[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();