use-bulk-selection.ts5.8 KBView on GitHub /**
* Bulk Selection Hook
* Handles multi-select functionality with keyboard modifiers (Shift, Cmd/Ctrl, Alt+Shift)
*/
import { useCallback, useEffect } from 'react';
import { useKeyState } from '@/hooks/use-hot-key';
export type SelectMode = 'single' | 'range' | 'mass' | 'selectAllBelow';
interface UseBulkSelectionOptions<T extends { id: string }> {
items: T[];
selectedIds: string[];
setSelection: (ids: string[]) => void;
toggleSelection: (id: string) => void;
clearSelection: () => void;
// Anchor ID for range selection (stored in global store for sharing with hotkeys)
anchorId: string | null;
setAnchorId: (id: string | null) => void;
}
export function useBulkSelection<T extends { id: string }>({
items,
selectedIds,
setSelection,
toggleSelection,
clearSelection,
anchorId,
setAnchorId,
}: UseBulkSelectionOptions<T>) {
// Compute anchor index from anchor ID
const anchorIndex = anchorId ? items.findIndex((item) => item.id === anchorId) : -1;
// Clear anchor and all selections on Escape key
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setAnchorId(null);
clearSelection();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [clearSelection, setAnchorId]);
// Keyboard state for selection modes
const isKeyPressed = useKeyState();
// Determine current selection mode based on keyboard state
const getSelectMode = useCallback((): SelectMode => {
const isAltPressed = isKeyPressed('Alt') || isKeyPressed('AltLeft') || isKeyPressed('AltRight');
const isShiftPressed =
isKeyPressed('Shift') || isKeyPressed('ShiftLeft') || isKeyPressed('ShiftRight');
const isCtrlPressed = isKeyPressed('Control') || isKeyPressed('Meta');
if (isShiftPressed && !isCtrlPressed) {
return 'range';
}
if (isCtrlPressed) {
return 'mass';
}
if (isAltPressed && isShiftPressed) {
return 'selectAllBelow';
}
return 'single';
}, [isKeyPressed]);
// Handle item selection based on mode
const handleSelect = useCallback(
(itemId: string) => {
const currentMode = getSelectMode();
const clickedIndex = items.findIndex((item) => item.id === itemId);
if (clickedIndex === -1) {
return;
}
switch (currentMode) {
case 'mass': {
toggleSelection(itemId);
break;
}
case 'selectAllBelow': {
if (clickedIndex !== -1) {
const itemsBelow = items.slice(clickedIndex);
const idsBelow = itemsBelow.map((item) => item.id);
setSelection(idsBelow);
} else {
setSelection([itemId]);
}
break;
}
case 'range': {
if (anchorIndex === -1) {
setSelection([itemId]);
break;
}
const start = Math.min(anchorIndex, clickedIndex);
const end = Math.max(anchorIndex, clickedIndex);
const rangeIds = items.slice(start, end + 1).map((item) => item.id);
const newSelected = [...new Set([...selectedIds, ...rangeIds])];
setSelection(newSelected);
break;
}
default: {
setSelection([itemId]);
break;
}
}
},
[getSelectMode, items, toggleSelection, setSelection, anchorIndex, selectedIds],
);
// Handle click with selection logic
const handleClickWithSelection = useCallback(
(itemId: string, onSingleSelect?: () => void) => {
const mode = getSelectMode();
const clickedIndex = items.findIndex((item) => item.id === itemId);
if (clickedIndex === -1) {
return;
}
if (mode === 'mass') {
// Mass mode (Cmd/Ctrl): toggle individual items, set anchor for future range selections
setAnchorId(itemId);
toggleSelection(itemId);
return;
}
if (mode === 'range') {
// Range mode (Shift): select from anchor to clicked item, ADDITIVE to existing selection
if (anchorIndex === -1) {
// No anchor set yet, just select this item and set anchor
setAnchorId(itemId);
// Add to existing selection instead of replacing
const newSelection = [...new Set([...selectedIds, itemId])];
setSelection(newSelection);
} else {
const start = Math.min(anchorIndex, clickedIndex);
const end = Math.max(anchorIndex, clickedIndex);
const rangeIds = items.slice(start, end + 1).map((item) => item.id);
// Add range to existing selection (additive)
const newSelection = [...new Set([...selectedIds, ...rangeIds])];
setSelection(newSelection);
}
return;
}
if (mode === 'selectAllBelow') {
// Select all items from clicked to end
setAnchorId(itemId);
const itemsBelow = items.slice(clickedIndex);
const idsBelow = itemsBelow.map((item) => item.id);
setSelection(idsBelow);
return;
}
// Single mode: clear all other selections, select only this item, and set anchor
setAnchorId(itemId);
setSelection([itemId]);
// Then execute the single select callback
if (onSingleSelect) {
onSingleSelect();
}
},
[getSelectMode, items, setSelection, toggleSelection, anchorIndex, selectedIds, setAnchorId],
);
// Toggle selection AND set anchor (for checkbox clicks that should enable future range selection)
const toggleAndSetAnchor = useCallback(
(itemId: string) => {
setAnchorId(itemId);
toggleSelection(itemId);
},
[toggleSelection, setAnchorId],
);
return {
getSelectMode,
handleSelect,
handleClickWithSelection,
toggleAndSetAnchor,
anchorId,
anchorIndex,
};
}