@ricsam/selection-manager
v0.0.28
Published
A selection manager for building spreadsheet-like React grids with keyboard navigation, editing, copy/paste, and fill-handle interactions.
Maintainers
Readme
🎯 Selection Manager
A powerful React hook-based selection manager that makes building spreadsheet-like interfaces a breeze! ✨
Transform any grid into an Excel-like powerhouse with intuitive multi-selection, keyboard navigation, copy/paste operations, and blazing-fast performance. Whether you're building the next Google Sheets or just need some fancy data tables, SelectionManager has got you covered! 🚀
✨ Features That'll Make You Smile
- 🎯 Multi-selection magic - Select ranges like a pro with Ctrl/Cmd + click
- ⌨️ Keyboard ninja mode - Arrow keys, shortcuts, and everything you'd expect
- 🖱️ Mouse interactions - Click, drag, double-click to edit - it just works!
- ✏️ Cell editing - F2 or double-click for instant editing with smart keyboard handling
- 🎨 Visual feedback - Beautiful borders and shadows that make selections pop
- 🖱️ Hover detection - Know exactly which cells and headers users are hovering over
- 🔗 Merged cell support - Handle grouped/merged cells like a real spreadsheet
- 🎯 Fill handle - Excel-style drag-to-fill functionality for extending data patterns
- 📊 Data export - Copy/paste TSV like you're in Excel (because why not?)
- ♾️ Infinite grids - Go crazy with millions of rows and columns
- 🔄 Real-time updates - Everything stays in sync, always
- ⚡ Stupid fast - Optimized for grids with thousands of cells
- 🎛️ Flexible as yoga - Works with React hooks or raw DOM manipulation
🚀 Quick Install
# Using bun (because it's fast!)
bun add selection-manager
# Or your favorite package manager
npm install selection-manager
yarn add selection-manager
pnpm add selection-manager📦 What's included:
import {
// Main hooks
useInitializeSelectionManager,
useSelectionManager,
// Core class
SelectionManager,
// Utility functions
parseCSVContent,
writeToClipboard,
// Types
type CellData,
type SMSelection,
type SelectionManagerState
} from 'selection-manager';🎮 Quick Start - Let's Build Something Cool!
Here's how to get started in less than 5 minutes:
import React, { useState } from 'react';
import { useInitializeSelectionManager, useSelectionManager } from 'selection-manager';
function MyAwesomeGrid() {
const [containerElement, setContainerElement] = useState(null);
// 🎉 This one hook does all the heavy lifting!
const selectionManager = useInitializeSelectionManager({
getNumRows: () => 10, // Your grid size
getNumCols: () => 10,
containerElement // Auto-magic event handling!
});
// 📡 Subscribe to selection changes (React-style!)
const { selections, hasFocus, boxShadow } = useSelectionManager(selectionManager, () => ({
selections: selectionManager.selections,
hasFocus: selectionManager.hasFocus,
boxShadow: selectionManager.getCellBoxShadow({ row, col }),
}));
return (
<div
ref={setContainerElement}
style={{
outline: 'none',
display: 'grid',
gridTemplateColumns: 'repeat(10, 60px)',
gap: '1px',
padding: '20px',
backgroundColor: '#f5f5f5'
}}
>
{Array.from({ length: 100 }, (_, i) => {
const row = Math.floor(i / 10);
const col = i % 10;
const isSelected = selectionManager.isSelected({ row, col });
return (
<div
key={i}
style={{
height: '40px',
backgroundColor: isSelected ? '#e3f2fd' : 'white',
border: '1px solid #ddd',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
// ✨ Magic selection borders!
boxShadow
}}
onMouseDown={(e) => {
selectionManager.cellMouseDown(row, col, {
shiftKey: e.shiftKey,
ctrlKey: e.ctrlKey,
metaKey: e.metaKey
});
}}
onMouseEnter={() => {
selectionManager.cellMouseEnter(row, col);
}}
>
{row},{col}
</div>
);
})}
</div>
);
}🎊 That's it! You now have a fully functional grid with:
- ✅ Click and drag selection
- ✅ Ctrl/Cmd+click for multi-selection
- ✅ Shift+click to extend selections
- ✅ Arrow key navigation
- ✅ Ctrl/Cmd+A to select all
- ✅ Beautiful visual feedback
🎨 Core Concepts Made Simple
🎯 The SelectionManager - Your New Best Friend
Think of SelectionManager as the brain of your grid. It knows what's selected, handles all the complex mouse/keyboard logic, and gives you simple methods to query and manipulate selections.
// 🧠 The brain that does it all
const selectionManager = useInitializeSelectionManager({
getNumRows: () => 1000, // Can be dynamic!
getNumCols: () => 50, // Even Infinity works!
containerElement // Pass this for auto-magic
});
// 🔍 Ask it anything about selections
const isSelected = selectionManager.isSelected({ row: 5, col: 3 });
const allSelected = selectionManager.isAllSelected();
const topLeftCell = selectionManager.getTopLeftCellInSelection();🎪 Mouse Interactions - Click, Drag, Repeat
Every mouse interaction has a purpose:
- 👆 Click: Select single cell
- 👆👆 Double Click: Start editing (like Excel!)
- 🖱️ Click + Drag: Select rectangular range
- ⌘ + Click: Add or remove from selection (multi-select!)
- ⇧ + Click: Extend current selection
- 📊 Header Click: Select entire row or column
⌨️ Keyboard Shortcuts - For the Power Users
We've got all the shortcuts you expect (and they're smart about editing mode):
| Shortcut | Action | Available When |
|----------|--------|----------------|
| Arrow Keys | Navigate selection | Not editing |
| Shift + Arrows | Extend selection | Not editing |
| Ctrl/Cmd + A | Select all | Not editing |
| Ctrl/Cmd + C | Copy selection | Not editing |
| Ctrl/Cmd + X | Cut selection | Not editing |
| Delete/Backspace | Clear cells | Not editing |
| F2 | Start editing | Always |
| Escape | Cancel editing or clear selection | Always |
🎨 Visual Magic - Borders That Make Sense
SelectionManager automatically generates beautiful CSS for you:
- 🔵 Blue borders: Your committed selections
- ⚫ Gray borders: The selection you're currently making
- 🟢 Green borders: Active row/column headers
- 🤎 Brown borders: Cells and headers being hovered
// ✨ Just apply the magic CSS!
<div style={{
boxShadow: useSelectionManager(selectionManager, () => selectionManager.getCellBoxShadow({ row, col }))
}}>
My Cell
</div>🏗️ Real-World Examples
📊 Building a Data Table with Headers
import React, { useState } from 'react';
import { useInitializeSelectionManager, useSelectionManager } from 'selection-manager';
function DataTable({ data }) {
const [containerElement, setContainerElement] = useState(null);
const selectionManager = useInitializeSelectionManager({
getNumRows: () => data.length,
getNumCols: () => data[0]?.length || 0,
containerElement
});
return (
<div
ref={setContainerElement}
className="data-table"
tabIndex={0}
style={{ outline: 'none' }}
>
{/* 📋 Column headers */}
<div className="header-row">
<div className="corner-cell" />
{data[0]?.map((_, colIndex) => (
<ColumnHeader
key={colIndex}
index={colIndex}
selectionManager={selectionManager}
/>
))}
</div>
{/* 📊 Data rows */}
{data.map((row, rowIndex) => (
<div key={rowIndex} className="data-row">
<RowHeader
index={rowIndex}
selectionManager={selectionManager}
/>
{row.map((cellData, colIndex) => (
<DataCell
key={`${rowIndex}-${colIndex}`}
row={rowIndex}
col={colIndex}
data={cellData}
selectionManager={selectionManager}
/>
))}
</div>
))}
</div>
);
}
// 🏷️ Smart column header component
function ColumnHeader({ index, selectionManager }) {
const isSelected = useSelectionManager(
selectionManager,
() => selectionManager.isWholeColSelected(index)
);
return (
<div
className={`column-header ${isSelected ? 'selected' : ''}`}
style={{
boxShadow: selectionManager.getHeaderBoxShadow(index, 'col')
}}
onMouseDown={(e) => {
selectionManager.headerMouseDown(index, 'col', {
shiftKey: e.shiftKey,
ctrlKey: e.ctrlKey,
metaKey: e.metaKey
});
}}
onMouseEnter={() => {
selectionManager.headerMouseEnter(index, 'col');
}}
>
{String.fromCharCode(65 + index)} {/* A, B, C... */}
</div>
);
}
// 🔢 Smart row header component
function RowHeader({ index, selectionManager }) {
const isSelected = useSelectionManager(
selectionManager,
() => selectionManager.isWholeRowSelected(index)
);
return (
<div
className={`row-header ${isSelected ? 'selected' : ''}`}
style={{
boxShadow: selectionManager.getHeaderBoxShadow(index, 'row')
}}
onMouseDown={(e) => {
selectionManager.headerMouseDown(index, 'row', {
shiftKey: e.shiftKey,
ctrlKey: e.ctrlKey,
metaKey: e.metaKey
});
}}
onMouseEnter={() => {
selectionManager.headerMouseEnter(index, 'row');
}}
>
{index + 1}
</div>
);
}✏️ Editable Spreadsheet Experience
function EditableSpreadsheet() {
const [data, setData] = useState(() => {
// 🎲 Generate some demo data
const grid = new Map();
for (let row = 0; row < 20; row++) {
for (let col = 0; col < 10; col++) {
grid.set(`${row},${col}`, `Cell ${row},${col}`);
}
}
return grid;
});
const [containerElement, setContainerElement] = useState(null);
const selectionManager = useInitializeSelectionManager({
getNumRows: () => 20,
getNumCols: () => 10,
containerElement
// 💡 By default, auto clipboard handling is enabled (copy/cut/paste work automatically)
// Set disableAutoClipboard: true to handle clipboard operations manually
// disableAutoClipboard: true // Uncomment to handle clipboard manually
});
// 📋 Handle copy operations like a pro
// Note: This example shows manual clipboard handling.
// If disableAutoClipboard is false (default), you can skip this and paste handling.
useEffect(() => {
return selectionManager.listenToCopy((isCut) => {
const boundingRect = selectionManager.getSelectionsBoundingRect();
if (!boundingRect) return;
// 🧮 Create a proper grid for export
const height = boundingRect.end.row - boundingRect.start.row + 1;
const width = boundingRect.end.col - boundingRect.start.col + 1;
const exportGrid = Array(height).fill(null).map(() => Array(width).fill(""));
// 🎯 Fill only the selected cells
selectionManager.forEachSelectedCell(({ absolute, relative }) => {
const value = data.get(`${absolute.row},${absolute.col}`) || "";
exportGrid[relative.row][relative.col] = value;
});
// 📋 Copy to clipboard as TSV (Excel-compatible!)
const tsvString = exportGrid.map(row => row.join('\t')).join('\n');
navigator.clipboard.writeText(tsvString);
if (isCut) {
// 🗑️ Clear the cut cells
selectionManager.getNonOverlappingSelections().forEach(selection => {
for (let row = selection.start.row; row <= selection.end.row; row++) {
for (let col = selection.start.col; col <= selection.end.col; col++) {
setData(prev => {
const newData = new Map(prev);
newData.set(`${row},${col}`, "");
return newData;
});
}
}
});
}
});
}, [data, selectionManager]);
// 📋 Handle paste operations - REQUIRED for paste to work!
// Note: By default, useInitializeSelectionManager automatically handles paste.
// If you need custom paste handling, set disableAutoClipboard: true and handle it yourself.
// This example shows manual handling - you can remove this if using auto clipboard handling.
useEffect(() => {
return selectionManager.listenToPaste(({ updates, rawString }) => {
// The clipboard content has been parsed and positioned at the current selection
// You must save these updates to make paste work:
selectionManager.saveCellValues(updates);
});
}, [selectionManager]);
// 📝 Handle data updates (from cell editing, paste, etc.)
useEffect(() => {
return selectionManager.listenToUpdateData((updates) => {
setData(prev => {
const newData = new Map(prev);
updates.forEach(({ rowIndex, colIndex, value }) => {
newData.set(`${rowIndex},${colIndex}`, value);
});
return newData;
});
});
}, [selectionManager]);
// 🎯 Custom CSV import example
const handleCsvImport = (csvText: string) => {
const cellData = parseCSVContent(csvText);
const topLeft = selectionManager.getTopLeftCellInSelection() || { row: 0, col: 0 };
// Import at current selection position
setData(prev => {
const newData = new Map(prev);
cellData.forEach(({ rowIndex, colIndex, value }) => {
const targetRow = topLeft.row + rowIndex;
const targetCol = topLeft.col + colIndex;
newData.set(`${targetRow},${targetCol}`, value);
});
return newData;
});
};
return (
<div className="spreadsheet-container">
<div className="toolbar">
<button onClick={() => {
const tsv = selectionManager.selectionToTsv(data);
console.log('📊 Exported data:', tsv);
}}>
📊 Export Selection
</button>
<span className="selection-info">
{selectionManager.hasSelection() ?
`Selected: ${selectionManager.getState().selections.length} range(s)` :
'No selection'}
</span>
</div>
<div
ref={setContainerElement}
className="spreadsheet-grid"
tabIndex={0}
>
{Array.from({ length: 20 }, (_, row) =>
Array.from({ length: 10 }, (_, col) => (
<EditableCell
key={`${row}-${col}`}
row={row}
col={col}
data={data}
selectionManager={selectionManager}
/>
))
)}
</div>
</div>
);
}
// ✏️ A cell that can be edited
const EditableCell = React.memo(({ row, col, data, selectionManager }) => {
const isEditing = useSelectionManager(
selectionManager,
() => selectionManager.isEditingCell(row, col)
);
const isHovering = useSelectionManager(
selectionManager,
() => selectionManager.isHoveringCell(row, col)
);
const cellValue = data.get(`${row},${col}`) || '';
if (isEditing) {
return (
<input
className="cell-editor"
autoFocus
defaultValue={cellValue} // 🔑 Use defaultValue, not value!
onBlur={() => selectionManager.cancelEditing()} // 🔥 Always cancel on blur
onKeyDown={(e) => {
if (e.key === 'Enter') {
// 💾 Save using saveCellValue - triggers listenToUpdateData!
selectionManager.saveCellValue(
{ rowIndex: row, colIndex: col },
e.target.value
);
selectionManager.cancelEditing();
} else if (e.key === 'Escape') {
selectionManager.cancelEditing();
}
}}
/>
);
}
return (
<div
className={`spreadsheet-cell ${isHovering ? 'hovering' : ''}`}
style={{
boxShadow: selectionManager.getCellBoxShadow({ row, col }),
// 🖱️ You can add custom hover styling too!
cursor: isHovering ? 'pointer' : 'default'
}}
onMouseDown={(e) => {
selectionManager.cellMouseDown(row, col, {
shiftKey: e.shiftKey,
ctrlKey: e.ctrlKey,
metaKey: e.metaKey
});
}}
onMouseEnter={() => {
selectionManager.cellMouseEnter(row, col);
}}
onDoubleClick={() => {
selectionManager.cellDoubleClick(row, col);
}}
>
{cellValue}
</div>
);
});🚀 High-Performance Grid (For the Speed Demons)
When you need to handle thousands of cells, use the DOM setup approach for maximum performance:
import React, { useCallback, useState } from 'react';
// 🏎️ Optimized cell component
const HighPerformanceCell = React.memo(({ row, col, selectionManager, data }) => {
// 🔑 Critical: useCallback prevents ref recreation
const cellRef = useCallback((el) => {
if (el) {
// ✨ This does ALL the work for you:
// - Event listeners
// - Style updates
// - State synchronization
return selectionManager.setupCellElement(el, { row, col });
}
}, [row, col, selectionManager]);
return (
<div
ref={cellRef}
className="performance-cell"
style={{
width: 60,
height: 30,
border: "1px solid #e0e0e0",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "12px",
cursor: "pointer",
backgroundColor: "white"
}}
>
{data.get(`${row},${col}`) || `${row},${col}`}
</div>
);
});
function MassiveGrid() {
const [containerElement, setContainerElement] = useState(null);
const [data] = useState(() => {
// 🎲 Generate 50,000 cells of data
const grid = new Map();
for (let row = 0; row < 500; row++) {
for (let col = 0; col < 100; col++) {
grid.set(`${row},${col}`, Math.floor(Math.random() * 1000));
}
}
return grid;
});
const selectionManager = useInitializeSelectionManager({
getNumRows: () => 500,
getNumCols: () => 100,
containerElement
});
return (
<div className="massive-grid-container">
<h2>🚀 50,000 Cells - Still Smooth as Butter!</h2>
<div
ref={setContainerElement}
tabIndex={0}
style={{
display: "grid",
gridTemplateColumns: "repeat(100, 60px)",
gap: "0",
height: "400px",
overflow: "auto",
outline: "none",
border: "2px solid #ddd"
}}
>
{Array.from({ length: 500 }, (_, row) =>
Array.from({ length: 100 }, (_, col) => (
<HighPerformanceCell
key={`${row}-${col}`}
row={row}
col={col}
selectionManager={selectionManager}
data={data}
/>
))
)}
</div>
</div>
);
}🎮 Controlled Mode - Take Full Control
Sometimes you want to manage the selection state yourself:
function ControlledExample() {
const [selectionState, setSelectionState] = useState({
selections: [],
hasFocus: false,
isSelecting: { type: "none" },
isEditing: { type: "none" }
});
const selectionManager = useInitializeSelectionManager({
getNumRows: () => 10,
getNumCols: () => 10,
state: selectionState, // 🎛️ You control the state
onStateChange: setSelectionState // 📡 Get notified of changes
});
// 🎯 Now you can manipulate selections programmatically!
const selectTopLeftCorner = () => {
setSelectionState(prev => ({
...prev,
selections: [{ start: { row: 0, col: 0 }, end: { row: 2, col: 2 } }]
}));
};
const clearAllSelections = () => {
setSelectionState(prev => ({
...prev,
selections: []
}));
};
const clearHovering = () => {
// 🖱️ Clear any hovering state programmatically
selectionManager.cancelHovering();
};
return (
<div>
<div className="controls">
<button onClick={selectTopLeftCorner}>
🎯 Select Top-Left 3x3
</button>
<button onClick={clearAllSelections}>
🧹 Clear All
</button>
<button onClick={clearHovering}>
🖱️ Clear Hover
</button>
</div>
<Grid selectionManager={selectionManager} />
</div>
);
}🧠 Deep Dive: API Reference
🎯 Core Types (TypeScript Goodness)
// 📐 A selection is just a rectangle
type SMSelection = {
start: { row: number; col: number };
end: { row: number; col: number };
};
// 🎪 What kind of selection is happening?
type IsSelecting =
| { type: "none" } // Nothing happening
| { type: "drag"; ...SMSelection } // Normal drag selection
| { type: "add"; ...SMSelection } // Ctrl+click adding
| { type: "remove"; ...SMSelection } // Ctrl+click removing
| { type: "shift"; ...SMSelection }; // Shift+click extending
// ✏️ Editing state
type IsEditing =
| { type: "none" } // Not editing
| { type: "cell"; row: number; col: number }; // Editing this cell
// 🧠 The complete state
type SelectionManagerState = {
hasFocus: boolean; // Is the grid focused?
selections: SMSelection[]; // All current selections
isSelecting: IsSelecting; // Current selection operation
isEditing: IsEditing; // Current editing state
isHovering: IsHovering; // Current hovering state
};
// 🖱️ Hovering state
type IsHovering =
| { type: "none" } // Not hovering
| { type: "cell"; row: number; col: number } // Hovering over this cell
| { type: "group"; group: SMArea } // Hovering over merged cell group
| { type: "header"; index: number; headerType: "row" | "col" }; // Hovering over header
// 📋 Paste event
type PasteEvent = {
updates: Array<{ rowIndex: number; colIndex: number; value: string }>; // Parsed cell data
rawString: string; // Original clipboard content before parsing
};
// 🔒 Mark cells that can be selected/copied but not changed
type ReadonlyCellPredicate = (cell: {
rowIndex: number;
colIndex: number;
}) => boolean;🎨 Visual Styling Methods
// ✨ Get beautiful CSS for your cells
const cellShadow = selectionManager.getCellBoxShadow({ row: 2, col: 3 });
const headerShadow = selectionManager.getHeaderBoxShadow(2, 'row');
const containerShadow = selectionManager.getContainerBoxShadow();
// 🎨 Or build your own with border information
const borders = selectionManager.selectionBorders({ row: 2, col: 3 });
// Returns: Array<"left" | "right" | "top" | "bottom">🔍 Selection Query Methods
// 🤔 Is this cell selected?
const isSelected = selectionManager.isSelected({ row: 5, col: 3 });
// 📊 Is this entire row/column selected?
const rowSelected = selectionManager.isWholeRowSelected(5);
const colSelected = selectionManager.isWholeColSelected(3);
// 🌍 Is everything selected?
const allSelected = selectionManager.isAllSelected();
// 🎯 Where's the action happening?
const topLeft = selectionManager.getTopLeftCellInSelection();
// 📐 What's the smallest box containing all selections?
const boundingRect = selectionManager.getSelectionsBoundingRect();
// 🧮 Break down overlapping selections
const cleanSelections = selectionManager.getNonOverlappingSelections();
// 🔄 Iterate through every selected cell
selectionManager.forEachSelectedCell(({ absolute, relative }) => {
console.log(`Cell ${absolute.row},${absolute.col} -> Grid pos ${relative.row},${relative.col}`);
});
// 🖱️ Is this cell being hovered?
const isHovering = selectionManager.isHoveringCell(row, col);
// 🔒 Is this cell readonly?
const readonly = selectionManager.isCellReadonly(rowIndex, colIndex);
// 🧹 Cancel any hovering state
selectionManager.cancelHovering();
// 💾 Save a cell value (triggers listenToUpdateData listeners)
selectionManager.saveCellValue(
{ rowIndex: 2, colIndex: 3 },
"New Value"
);
// 💾 Save multiple cell values at once
selectionManager.saveCellValues([
{ rowIndex: 0, colIndex: 0, value: "A1" },
{ rowIndex: 0, colIndex: 1, value: "B1" },
{ rowIndex: 1, colIndex: 0, value: "A2" }
]);
// 🔗 Group/merged cell operations
const group = selectionManager.findGroupContainingCell({ row: 2, col: 3 });
const isHoveringGroup = selectionManager.isHoveringGroup(group);
const groupShadow = selectionManager.getBoxShadow({ color: '#4CAF50' });
// 🎯 Fill handle operations
const canShowFillHandle = selectionManager.canCellHaveFillHandle({ row: 2, col: 3 });
const fillBaseSelection = selectionManager.getFillHandleBaseSelection();🛠️ DOM Setup Methods (High-Performance Mode)
These methods automatically handle event listeners, styling updates, and state synchronization. They update the DOM directly without React re-renders, making them ideal for grids with thousands of cells.
// Cell setup - handles mouse events, visual updates, and input capture positioning
const cleanupCell = selectionManager.setupCellElement(el, { row, col });
// Header setup - handles mouse events and visual updates
const cleanupHeader = selectionManager.setupHeaderElement(el, index, 'row');
// Container setup - handles all global events (keyboard, paste, drag/drop, focus)
const cleanupContainer = selectionManager.setupContainerElement(el);
// Input setup - handles blur, Enter/Tab to save, focus management
const cleanupInput = selectionManager.setupInputElement(inputEl, { rowIndex, colIndex });setupCellElement automatically:
- Sets up mouse event listeners (mousedown, mouseenter, dblclick)
- Updates boxShadow style when selections change
- Detects fill handle interactions via
data-fill-handleattribute - Positions input capture element when cell becomes active
setupHeaderElement automatically:
- Sets up mouse event listeners (mousedown, mouseenter)
- Updates boxShadow for header selection/hover states
setupContainerElement automatically:
- Handles global mouse events (mouseup, mousedown for selection completion)
- Handles all keyboard shortcuts (arrows, Ctrl+C/V/X, etc.)
- Handles paste events
- Handles drag & drop for CSV/TSV files
- Prevents text selection during drag operations
- Creates and manages invisible input capture element for keyboard input
- Updates container boxShadow on focus changes
- Clears hover state when mouse leaves container
setupInputElement automatically:
- Saves cell value on blur
- Handles Enter/Tab keys to save
- Sets initial value from editing state
- Focuses input and selects all text
Important: Always use useCallback for refs to prevent unnecessary re-setup. Call cleanup functions when components unmount. setupContainerElement should be called once per grid.
✏️ Cell Editing Best Practices
When implementing cell editing, follow these patterns for the best user experience:
// 🔑 Key editing patterns
const EditingCell = ({ row, col, selectionManager, initialValue }) => {
return (
<input
autoFocus // 🎯 Focus immediately
defaultValue={initialValue} // 🔑 Use defaultValue, NOT value
onBlur={() => {
// 🔥 Always cancel on blur
selectionManager.cancelEditing();
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
// 💾 Save and exit - this triggers listenToUpdateData listeners!
selectionManager.saveCellValue(
{ rowIndex: row, colIndex: col },
e.target.value
);
selectionManager.cancelEditing();
} else if (e.key === 'Escape') {
// ⏭️ Cancel without saving
selectionManager.cancelEditing();
}
}}
style={{
width: "100%",
height: "100%",
border: "none",
outline: "none",
backgroundColor: "transparent",
textAlign: "center"
}}
/>
);
};🎯 Key Takeaways:
defaultValuenotvalue: UsedefaultValuefor better performance and to avoid React warnings- Always handle
onBlur: Cancel editing when the user clicks away - Use
saveCellValue(): This method automatically triggers alllistenToUpdateDatalisteners - Handle Enter and Escape: Standard spreadsheet behavior users expect
🎯 Fill Handle - Excel-Style Data Extension
The fill handle lets users drag from the bottom-right corner of a selection to extend data patterns, just like in Excel:
function CellWithFillHandle({ row, col, selectionManager, data }) {
const canHaveFillHandle = useSelectionManager(
selectionManager,
() => selectionManager.canCellHaveFillHandle({ row, col })
);
return (
<div
className="cell"
onMouseDown={(e) => {
const isFillHandle =
e.target instanceof HTMLElement &&
(e.target.hasAttribute("data-fill-handle") ||
e.target.querySelector("[data-fill-handle]") !== null);
selectionManager.cellMouseDown(row, col, {
shiftKey: e.shiftKey,
ctrlKey: e.ctrlKey,
metaKey: e.metaKey,
isFillHandle // 🔑 Key parameter for fill handle detection
});
}}
// ... other props
>
{/* Your cell content */}
Cell content here
{/* 🎯 Fill handle - only shows on bottom-right cell of selection */}
{canHaveFillHandle && (
<div
data-fill-handle={true} // 🔑 Required attribute
style={{
position: "absolute",
bottom: 0,
right: 0,
width: 8,
height: 8,
backgroundColor: "blue",
cursor: "crosshair", // Excel-style cursor
}}
/>
)}
</div>
);
}
// 🎧 Listen for fill operations
React.useEffect(() => {
return selectionManager.listenToFill((baseSelection, fillArea) => {
console.log("Fill operation:", { from: baseSelection, to: fillArea });
// Implement your fill logic here
// Example: extend patterns, copy data, generate sequences, etc.
const updates = generateFillData(baseSelection, fillArea);
selectionManager.saveCellValues(updates);
});
}, [selectionManager]);🎯 Key Fill Handle Features:
- Automatic detection: Use
canCellHaveFillHandle()to check if a cell should show the handle - Visual feedback: Fill operations show with red borders during drag
- Smart direction: Automatically detects row-wise vs column-wise fill based on drag direction
- Event driven: Use
listenToFill()to implement your own data extension logic - Excel-like UX: Familiar crosshair cursor and bottom-right corner positioning
🎨 Fill Handle Styling:
// The fill handle gets special styling during drag operations
const cellBoxShadow = selectionManager.getCellBoxShadow({ row, col });
// During fill operations, this returns red borders instead of blue
// You can also detect fill mode programmatically
const isFillMode = selectionManager.isSelecting.type === "fill";🔗 Merged Cell Groups (Advanced)
For spreadsheet-like applications with merged cells, SelectionManager supports grouped cells:
const selectionManager = useInitializeSelectionManager({
getNumRows: () => 10,
getNumCols: () => 5,
// 🔗 Define merged cell areas
getGroups: () => {
// Return areas that should be treated as merged cells
return [
{ start: { row: 1, col: 1 }, end: { row: 2, col: 3 } }, // 2x3 merged area
{ start: { row: 5, col: 0 }, end: { row: 5, col: 4 } }, // Merged row
];
}
});
// 🎯 Usage in components
const GroupedCell = ({ row, col, group, selectionManager }) => {
// Only render content in the top-left cell of a group
const isTopLeft = group && group.start.row === row && group.start.col === col;
const groupBoxShadow = useSelectionManager(selectionManager, () => {
return (
group &&
selectionManager.isHoveringGroup(group) &&
selectionManager.getBoxShadow({ color: '#4CAF50' })
) || undefined;
});
if (group && !isTopLeft) {
// Hidden cells in merged group
return <div style={{ display: 'none' }} />;
}
return (
<div
style={{
// Span multiple cells if this is a group
gridRowStart: row + 1,
gridRowEnd: group ? group.end.row + 2 : row + 2,
gridColumnStart: col + 1,
gridColumnEnd: group ? group.end.col + 2 : col + 2,
boxShadow: groupBoxShadow,
border: '1px solid #ddd',
padding: '8px'
}}
>
{group ? `Merged ${group.end.row - group.start.row + 1}x${group.end.col - group.start.col + 1}` : `${row},${col}`}
</div>
);
};🎯 Key Patterns:
- Dynamic groups:
getGroups()can return different areas based on data - Hover detection: Use
isHoveringGroup(group)to detect group hovers - Custom styling: Use
getBoxShadow()for group-specific visual feedback
🛠️ Utility Functions
SelectionManager exports helpful utility functions for data handling:
import { parseCSVContent, writeToClipboard, type CellData } from 'selection-manager';
// 📊 Parse CSV/TSV content into cell data format
type CellData = {
rowIndex: number;
colIndex: number;
value: string;
};
const csvData = "Name,Age,City\nJohn,25,NYC\nJane,30,LA";
const cells: CellData[] = parseCSVContent(csvData);
// Returns: [
// { rowIndex: 0, colIndex: 0, value: "Name" },
// { rowIndex: 0, colIndex: 1, value: "Age" },
// { rowIndex: 0, colIndex: 2, value: "City" },
// { rowIndex: 1, colIndex: 0, value: "John" },
// ...
// ]
// 📋 Write data to clipboard (with fallback for older browsers)
writeToClipboard("Hello\tWorld\nFoo\tBar"); // TSV format🎯 Smart Parsing Features:
- Auto-delimiter detection: Prefers tabs > commas > spaces
- Formatted numbers: Handles "1,234.56" as single values, not CSV
- Quote handling: Properly parses quoted CSV fields
- Cross-browser clipboard: Fallback for older browsers
📋 Data Operations (Copy/Paste Magic)
💡 Auto Clipboard Handling: By default, useInitializeSelectionManager automatically handles copy/cut/paste operations. Copy/cut triggers listenToCopy() with cut: true for cut and cut: false for copy, and automatically clears cells on cut. Paste automatically calls saveCellValues(). Set disableAutoClipboard: true to handle clipboard operations manually.
// 📋 Export selections as TSV
const dataMap = new Map([
["0,0", "Hello"],
["0,1", "World"],
["1,0", "42"],
["1,1", "🎉"]
]);
const tsv = selectionManager.selectionToTsv(dataMap);
// Returns: "Hello\tWorld\n42\t🎉" (only selected cells)
// 🎧 Listen for user actions (only needed if disableAutoClipboard: true)
const unsubscribeCopy = selectionManager.listenToCopy((isCut) => {
if (isCut) {
console.log("User cut data");
// Handle cut: copy to clipboard and clear cells
selectionManager.clearSelectedCells();
} else {
console.log("User copied data");
// Handle copy: copy to clipboard only
}
});
// 📋 Listen for paste operations - REQUIRED for paste to work!
// Note: Only needed if disableAutoClipboard: true. Otherwise, paste is handled automatically.
const unsubscribePaste = selectionManager.listenToPaste(({ updates, rawString }) => {
// rawString: string - The original clipboard content before parsing
// The clipboard content has been parsed and positioned at the current selection
// You must handle these updates, typically by saving them:
selectionManager.saveCellValues(updates);
});
const unsubscribeData = selectionManager.listenToUpdateData((data) => {
console.log("Data updated:", data);
// data: Array<{ rowIndex: number; colIndex: number; value: string }>
// This fires for: cell editing, paste operations, file drops, and manual saves
});
const unsubscribeFill = selectionManager.listenToFill((baseSelection, fillArea) => {
console.log("Fill operation:", { from: baseSelection, to: fillArea });
// baseSelection: The original selected area being extended from
// fillArea: The new area being filled (includes direction and extent)
// Implement your fill logic: copy data, extend patterns, generate sequences, etc.
const fillUpdates = generateDataForFillArea(baseSelection, fillArea);
selectionManager.saveCellValues(fillUpdates);
});
// 🗑️ Clear selected cells (triggers listenToUpdateData with empty values)
selectionManager.clearSelectedCells();
// 💾 Save single cell value (triggers listenToUpdateData)
selectionManager.saveCellValue({ rowIndex: 2, colIndex: 3 }, "New Value");
// 💾 Save multiple cell values (triggers listenToUpdateData)
selectionManager.saveCellValues([
{ rowIndex: 0, colIndex: 0, value: "A1" },
{ rowIndex: 0, colIndex: 1, value: "B1" }
]);
// 🧹 Clean up when done
unsubscribeCopy();
unsubscribePaste();
unsubscribeData();
unsubscribeFill();🔒 Readonly Cells
Readonly cells are still selectable, navigable, copyable, and usable as fill sources. Editing, paste, delete, cut clearing, drop imports, and saved fill updates skip readonly targets automatically.
const selectionManager = useInitializeSelectionManager({
getNumRows: () => ({ type: "number", value: rows.length }),
getNumCols: () => ({ type: "number", value: columns.length }),
isCellReadonly: ({ rowIndex, colIndex }) =>
rowIndex === 0 || columns[colIndex]?.readonly === true,
});
// Useful for styling locked cells in your grid.
const readonly = selectionManager.isCellReadonly(rowIndex, colIndex);🎪 Advanced Patterns
♾️ Infinite Grids - Go Wild!
function InfiniteGrid() {
const selectionManager = useInitializeSelectionManager({
getNumRows: () => Infinity, // 🤯 Infinite rows!
getNumCols: () => Infinity, // 🤯 Infinite columns!
});
// Selections can now have Infinity as end coordinates
// Perfect for virtualized grids!
}🎭 Custom State Observation
function SmartComponent() {
const selectionManager = useInitializeSelectionManager({/* ... */});
// 👀 Watch for specific state changes
useEffect(() => {
return selectionManager.observeStateChange(
(state) => state.isSelecting.type, // Watch selection type
(type) => {
if (type !== "none") {
console.log("Started selecting!");
// Return cleanup function
return () => console.log("Stopped selecting!");
}
},
true // Run immediately with current state
);
}, [selectionManager]);
// 🖱️ Watch for hovering state changes
useEffect(() => {
return selectionManager.observeStateChange(
(state) => state.isHovering,
(hovering) => {
if (hovering.type === "cell") {
console.log(`Hovering over cell ${hovering.row},${hovering.col}`);
} else if (hovering.type === "header") {
console.log(`Hovering over ${hovering.headerType} header ${hovering.index}`);
}
},
true
);
}, [selectionManager]);
}🎨 Custom Styling with Borders
function CustomStyledCell({ row, col, selectionManager }) {
const borders = useSelectionManager(
selectionManager,
() => selectionManager.selectionBorders({ row, col })
);
const customStyle = {
borderLeft: borders.includes('left') ? '2px solid #ff4081' : '1px solid #ddd',
borderRight: borders.includes('right') ? '2px solid #ff4081' : '1px solid #ddd',
borderTop: borders.includes('top') ? '2px solid #ff4081' : '1px solid #ddd',
borderBottom: borders.includes('bottom') ? '2px solid #ff4081' : '1px solid #ddd',
};
return <div style={customStyle}>Custom styled cell!</div>;
}🏆 Performance Tips
🚀 For Small Grids (< 1000 cells)
- Use the React hooks approach with
useSelectionManager - Manual event handlers are fine
- Easy to debug and understand
⚡ For Large Grids (> 1000 cells)
- Use
setupCellElementandsetupHeaderElement - Individual components with
React.memo useCallbackfor refs (critical!)- Avoid creating refs in loops
🎯 Best Practices
Always use
useCallbackfor refs:// ✅ Good const cellRef = useCallback((el) => { if (el) return selectionManager.setupCellElement(el, { row, col }); }, [row, col, selectionManager]); // ❌ Bad - creates new function every render const cellRef = (el) => { if (el) return selectionManager.setupCellElement(el, { row, col }); };Set up focus correctly:
<div ref={setContainerElement} tabIndex={0} // 🔑 Required for keyboard events style={{ outline: 'none' }} // 🎨 Remove ugly focus outline />Use specific selectors:
// ✅ Good - only re-renders when selections change const selections = useSelectionManager(sm, (state) => state.selections); // ❌ Bad - re-renders on any state change const state = useSelectionManager(sm, (state) => state);
🎉 That's a Wrap!
SelectionManager makes building grid interfaces fun instead of frustrating. Whether you're creating a simple data table or the next Excel competitor, we've got the tools to make it happen smoothly.
Quick links:
Now go build something amazing! 🚀✨
📄 License
MIT © ricsam - Go wild! 🎊
