mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-09 20:29:36 +02:00
implement list multiselection
This commit is contained in:
@@ -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();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user