mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-06 20:10:12 +02:00
implement list multiselection
This commit is contained in:
@@ -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,6 +21,78 @@ export const useDefaultItemListControls = () => {
|
||||
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
|
||||
// If this item is already the only selected item, deselect it
|
||||
const selectedItems = internalState.getSelected();
|
||||
@@ -32,6 +104,7 @@ export const useDefaultItemListControls = () => {
|
||||
} else {
|
||||
internalState.setSelected([itemListItem]);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
onDoubleClick: ({ internalState, item, itemType }: DefaultItemControlProps) => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user