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,
  };
}