@tanstack/table-core
Version:
Headless UI for building powerful tables & datagrids for TS/JS.
879 lines (877 loc) • 32 kB
JavaScript
import { callMemoOrStaticFn, cloneState, makeObjectMap } from "../../utils.js";
import { table_getVisibleLeafColumns } from "../column-visibility/columnVisibilityFeature.utils.js";
import { applyCellSelectionBoundsOperations, expandCellSelectionBounds } from "./cellSelectionGeometry.js";
//#region src/features/cell-selection/cellSelectionFeature.utils.ts
/**
* Creates the default cell selection state.
*
* The feature default is an empty selection. Reset APIs use this value when
* `defaultState` is `true`.
*
* @example
* ```ts
* const selection = getDefaultCellSelectionState()
* ```
*/
function getDefaultCellSelectionState() {
return [];
}
/**
* Routes a cell selection updater through the table's selection change handler.
*
* @example
* ```ts
* table_setCellSelection(table, (old) => old.slice(0, -1))
* ```
*/
function table_setCellSelection(table, updater) {
table.options.onCellSelectionChange?.(updater);
}
/**
* Resets `cellSelection` to the configured initial state or feature default.
*
* With no argument, the reset clones `table.initialState.cellSelection` when it
* exists. Passing `true` ignores initial state and resets to an empty selection.
*
* @example
* ```ts
* table_resetCellSelection(table, true)
* ```
*/
function table_resetCellSelection(table, defaultState) {
table_setCellSelection(table, defaultState ? getDefaultCellSelectionState() : cloneState(table.initialState.cellSelection) ?? getDefaultCellSelectionState());
}
/**
* Schedules a cell selection reset after `data` changes.
*
* Ranges are stored as row and column ids, so without this a data swap would
* leave a selection pointing at rows that no longer exist, or silently
* re-select cells whenever new data reuses ids. The reset runs when
* `autoResetAll` or `autoResetCellSelection` allows it, defaulting to on.
*
* Resetting to `initialState.cellSelection` rather than to empty means the
* first row-model computation is a no-op, matching `table_autoResetExpanded`.
*
* @example
* ```ts
* table_autoResetCellSelection(table)
* ```
*/
function table_autoResetCellSelection(table) {
if (!table.atoms.cellSelection) return;
if (table.options.autoResetAll ?? table.options.autoResetCellSelection ?? true) table._reactivity.schedule(() => table_resetCellSelection(table));
}
/**
* Returns the visible leaf columns in the order their cells actually render.
*
* This is deliberately not `getVisibleLeafColumns()`, which is
* visibility-filtered but *not* pinning-reordered, and not `column_getIndex()`,
* which indexes that same unpinned list. Cells render start-pinned first, then
* center, then end (see `row_getVisibleCells`), so indexing a selection in the
* unpinned order would make a dragged rectangle contiguous in index space but
* visually scattered the moment a column is pinned.
*
* The pinning read is inlined rather than delegated to the column pinning
* utils so this stays correct when that feature is absent, and so the ordering
* provably matches `row_getVisibleCells`.
*/
function getDisplayOrderedColumns(table) {
const columns = callMemoOrStaticFn(table, "getVisibleLeafColumns", table_getVisibleLeafColumns);
const pinning = table.atoms.columnPinning?.get();
if (!pinning || !pinning.start.length && !pinning.end.length) return columns;
const byId = makeObjectMap();
for (let i = 0; i < columns.length; i++) byId[columns[i].id] = columns[i];
const start = [];
for (let i = 0; i < pinning.start.length; i++) {
const column = byId[pinning.start[i]];
if (column) start.push(column);
}
const end = [];
for (let i = 0; i < pinning.end.length; i++) {
const column = byId[pinning.end[i]];
if (column) end.push(column);
}
const center = [];
for (let i = 0; i < columns.length; i++) {
const column = columns[i];
if (!pinning.start.includes(column.id) && !pinning.end.includes(column.id)) center.push(column);
}
return [
...start,
...center,
...end
];
}
/**
* Builds a column id to render-order index map.
*
* Registered by this feature so the lookup stays memoized even when
* `columnOrderingFeature` is absent, since that feature's `getColumnIndexes`
* static rebuilds all four maps on every call, which would make per-cell reads
* O(columns).
*
* @example
* ```ts
* const index = table_getCellSelectionColumnIndexes(table)[columnId]
* ```
*/
function table_getCellSelectionColumnIndexes(table) {
const columns = getDisplayOrderedColumns(table);
const indexes = makeObjectMap();
for (let i = 0; i < columns.length; i++) indexes[columns[i].id] = i;
return indexes;
}
const EMPTY_MERGE_BOUNDS = [];
function probeCellSpanIndex(table) {
return table.getCellSpanIndex?.();
}
/**
* Resolves the merged-cell rectangles of the rendered rows into selection's
* own index space.
*
* The span index positions rows by their paginated render order while
* selection positions them by pre-paginated display order, so each merge is
* mapped through `row.getDisplayIndex()`. A merge whose rows do not map to a
* contiguous display range is skipped defensively; it then behaves like
* unmerged cells instead of corrupting the geometry.
*
* Returns an empty array when `cellSpanningFeature` is not registered, which
* keeps every selection code path identical to the span-unaware behavior.
*
* @example
* ```ts
* const merges = table_getCellSelectionMergeBounds(table)
* ```
*/
function table_getCellSelectionMergeBounds(table) {
const spanIndex = probeCellSpanIndex(table);
if (!spanIndex) return EMPTY_MERGE_BOUNDS;
const columnIndexes = callMemoOrStaticFn(table, "getCellSelectionColumnIndexes", table_getCellSelectionColumnIndexes);
const merges = [];
for (const columnId in spanIndex.rowSpans) {
const columnIndex = columnIndexes[columnId];
if (columnIndex === void 0) continue;
const spans = spanIndex.rowSpans[columnId];
const spanColumnIndex = spanIndex.columnIndexes[columnId];
for (let r = 0; r < spans.length; r++) {
const span = spans[r];
if (span <= 1) continue;
const startRow = spanIndex.rows[r].getDisplayIndex();
const endRow = spanIndex.rows[r + span - 1].getDisplayIndex();
if (startRow < 0 || endRow - startRow !== span - 1) continue;
const colSpan = spanColumnIndex === void 0 ? 1 : Math.max(spanIndex.colSpans[r]?.[spanColumnIndex] ?? 1, 1);
merges.push({
minRowIndex: startRow,
maxRowIndex: endRow,
minColumnIndex: columnIndex,
maxColumnIndex: columnIndex + colSpan - 1
});
}
}
if (spanIndex.colSpans.length) {
const columnIdBySpanIndex = [];
for (const columnId in spanIndex.columnIndexes) columnIdBySpanIndex[spanIndex.columnIndexes[columnId]] = columnId;
for (let r = 0; r < spanIndex.colSpans.length; r++) {
const rowColSpans = spanIndex.colSpans[r];
if (!rowColSpans) continue;
const displayRow = spanIndex.rows[r]?.getDisplayIndex() ?? -1;
if (displayRow < 0) continue;
for (let c = 0; c < rowColSpans.length; c++) {
const span = rowColSpans[c];
if (span <= 1) continue;
const columnId = columnIdBySpanIndex[c];
if (columnId === void 0) continue;
const vertical = spanIndex.rowSpans[columnId];
if (vertical && vertical[r] !== 1) continue;
const columnIndex = columnIndexes[columnId];
if (columnIndex === void 0) continue;
merges.push({
minRowIndex: displayRow,
maxRowIndex: displayRow,
minColumnIndex: columnIndex,
maxColumnIndex: columnIndex + span - 1
});
}
}
}
return merges;
}
function findMergeBoundsAt(merges, rowIndex, columnIndex) {
for (let i = 0; i < merges.length; i++) {
const merge = merges[i];
if (rowIndex >= merge.minRowIndex && rowIndex <= merge.maxRowIndex && columnIndex >= merge.minColumnIndex && columnIndex <= merge.maxColumnIndex) return merge;
}
}
/**
* Resolves a row id to its display-order index, or `-1` when it no longer
* identifies a row in the current order.
*
* Callers must have already called `table.getRowsInDisplayOrder()`, which is
* what populates the display index cache each row reads.
*/
function resolveRowIndex(table, rows, rowId) {
const row = table.getPrePaginatedRowModel().rowsById[rowId] ?? table.getCoreRowModel().rowsById[rowId];
if (!row) return -1;
const index = row.getDisplayIndex();
if (index < 0 || index >= rows.length || rows[index]?.id !== rowId) return -1;
return index;
}
/**
* Resolves ordered range operations into disjoint, positive display-order
* index rectangles.
*
* This is the single cache every per-cell read goes through, so index lookups
* happen once per invalidation rather than once per cell. A range whose corners
* no longer resolve, for example because its anchor row was filtered out, is
* omitted rather than clamped, so it contributes nothing while remaining in
* state and returns intact when the filter clears.
*
* @example
* ```ts
* const bounds = table_getCellSelectionBounds(table)
* ```
*/
function table_getCellSelectionBounds(table) {
const ranges = table.atoms.cellSelection?.get();
if (!ranges?.length) return [];
const rows = table.getRowsInDisplayOrder();
const columnIndexes = callMemoOrStaticFn(table, "getCellSelectionColumnIndexes", table_getCellSelectionColumnIndexes);
const operations = [];
for (let i = 0; i < ranges.length; i++) {
const range = ranges[i];
const anchorRowIndex = resolveRowIndex(table, rows, range.anchorRowId);
const focusRowIndex = resolveRowIndex(table, rows, range.focusRowId);
const anchorColumnIndex = columnIndexes[range.anchorColumnId] ?? -1;
const focusColumnIndex = columnIndexes[range.focusColumnId] ?? -1;
if (anchorRowIndex < 0 || focusRowIndex < 0 || anchorColumnIndex < 0 || focusColumnIndex < 0) continue;
operations.push({
minRowIndex: Math.min(anchorRowIndex, focusRowIndex),
maxRowIndex: Math.max(anchorRowIndex, focusRowIndex),
minColumnIndex: Math.min(anchorColumnIndex, focusColumnIndex),
maxColumnIndex: Math.max(anchorColumnIndex, focusColumnIndex),
operation: range.operation ?? "include"
});
}
const merges = callMemoOrStaticFn(table, "getCellSelectionMergeBounds", table_getCellSelectionMergeBounds);
if (merges.length) for (let i = 0; i < operations.length; i++) {
const operation = operations[i];
const expanded = expandCellSelectionBounds(operation, merges);
operation.minRowIndex = expanded.minRowIndex;
operation.maxRowIndex = expanded.maxRowIndex;
operation.minColumnIndex = expanded.minColumnIndex;
operation.maxColumnIndex = expanded.maxColumnIndex;
}
return applyCellSelectionBoundsOperations(operations);
}
/**
* Tests whether an index pair falls inside any resolved rectangle.
*/
function isWithinBounds(bounds, rowIndex, columnIndex) {
for (let i = 0; i < bounds.length; i++) {
const bound = bounds[i];
if (rowIndex >= bound.minRowIndex && rowIndex <= bound.maxRowIndex && columnIndex >= bound.minColumnIndex && columnIndex <= bound.maxColumnIndex) return true;
}
return false;
}
/**
* Checks whether this cell can currently be selected.
*
* A column def opting out with `enableCellSelection: false` wins over the table
* option, matching how the other per-column enable flags resolve.
*
* @example
* ```ts
* const canSelect = cell_getCanSelect(cell)
* ```
*/
function cell_getCanSelect(cell) {
if (cell.column.columnDef.enableCellSelection === false) return false;
const enabled = cell.table.options.enableCellSelection;
if (typeof enabled === "function") return enabled(cell);
return enabled ?? true;
}
/**
* Resolves a cell to the coordinates every selection read needs.
*
* Shared by `getIsSelected` and `getSelectionEdges` so a render pass resolves
* each cell once. Resolving in both meant every cell paid for the bounds memo,
* the display index, and the column index map twice over.
*
* Returns `null` when the cell cannot participate in a selection at all.
*/
function resolveCellPosition(cell) {
const table = cell.table;
const bounds = callMemoOrStaticFn(table, "getCellSelectionBounds", table_getCellSelectionBounds);
if (!bounds.length) return null;
if (!callMemoOrStaticFn(cell, "getCanSelect", cell_getCanSelect)) return null;
const rowIndex = cell.row.getDisplayIndex();
if (rowIndex < 0) return null;
const columnIndex = callMemoOrStaticFn(table, "getCellSelectionColumnIndexes", table_getCellSelectionColumnIndexes)[cell.column.id] ?? -1;
if (columnIndex < 0) return null;
return {
bounds,
rowIndex,
columnIndex
};
}
/**
* Checks whether this cell falls inside the final positive selection.
*
* Deliberately not memoized. Registering this through `assignPrototypeAPIs`
* with `memoDeps` would allocate a memo closure and dependency array per cell,
* which costs more than the handful of integer comparisons it would save.
*
* @example
* ```ts
* const isSelected = cell_getIsSelected(cell)
* ```
*/
function cell_getIsSelected(cell) {
const position = resolveCellPosition(cell);
if (!position) return false;
return isWithinBounds(position.bounds, position.rowIndex, position.columnIndex);
}
/**
* Checks whether this cell is the active cell.
*
* @example
* ```ts
* const isFocused = cell_getIsFocused(cell)
* ```
*/
function cell_getIsFocused(cell) {
const ranges = cell.table.atoms.cellSelection?.get();
const active = ranges?.[ranges.length - 1];
if (!active) return false;
return active.anchorRowId === cell.row.id && active.anchorColumnId === cell.column.id;
}
/**
* Returns `0` for the focused cell and `-1` otherwise, for roving tabindex.
*
* @example
* ```ts
* const tabIndex = cell_getTabIndex(cell)
* ```
*/
function cell_getTabIndex(cell) {
return callMemoOrStaticFn(cell, "getIsFocused", cell_getIsFocused) ? 0 : -1;
}
/**
* Returns which sides of this cell sit on the outer boundary of the selection.
*
* A side is an edge when the neighbouring cell in that direction is not itself
* covered by a range, which is what lets a consumer draw a single outline
* around an arbitrary union of rectangles.
*
* @example
* ```ts
* const { top, right, bottom, left } = cell_getSelectionEdges(cell)
* ```
*/
function cell_getSelectionEdges(cell) {
const none = {
top: false,
right: false,
bottom: false,
left: false
};
const position = resolveCellPosition(cell);
if (!position) return none;
const { bounds, rowIndex, columnIndex } = position;
if (!isWithinBounds(bounds, rowIndex, columnIndex)) return none;
const merges = callMemoOrStaticFn(cell.table, "getCellSelectionMergeBounds", table_getCellSelectionMergeBounds);
const merge = merges.length ? findMergeBoundsAt(merges, rowIndex, columnIndex) : void 0;
if (!merge) return {
top: !isWithinBounds(bounds, rowIndex - 1, columnIndex),
right: !isWithinBounds(bounds, rowIndex, columnIndex + 1),
bottom: !isWithinBounds(bounds, rowIndex + 1, columnIndex),
left: !isWithinBounds(bounds, rowIndex, columnIndex - 1)
};
return {
top: isStripOutside(bounds, merge.minRowIndex - 1, merge.minColumnIndex, merge.maxColumnIndex, true),
right: isStripOutside(bounds, merge.maxColumnIndex + 1, merge.minRowIndex, merge.maxRowIndex, false),
bottom: isStripOutside(bounds, merge.maxRowIndex + 1, merge.minColumnIndex, merge.maxColumnIndex, true),
left: isStripOutside(bounds, merge.minColumnIndex - 1, merge.minRowIndex, merge.maxRowIndex, false)
};
}
function isStripOutside(bounds, fixedIndex, from, to, fixedIsRow) {
for (let i = from; i <= to; i++) if (!isWithinBounds(bounds, fixedIsRow ? fixedIndex : i, fixedIsRow ? i : fixedIndex)) return true;
return false;
}
/**
* Returns the active cell, i.e. the anchor of the most recent operation.
*
* Focus is derived rather than stored: in spreadsheet semantics, dragging from
* A1 to C5 leaves the active cell at A1, so the active range's anchor already
* is the active cell.
*
* @example
* ```ts
* const cell = table_getFocusedCell(table)
* ```
*/
function table_getFocusedCell(table) {
const ranges = table.atoms.cellSelection?.get();
const active = ranges?.[ranges.length - 1];
if (!active) return void 0;
return (table.getPrePaginatedRowModel().rowsById[active.anchorRowId] ?? table.getCoreRowModel().rowsById[active.anchorRowId])?.getAllCellsByColumnId()[active.anchorColumnId];
}
/**
* Collapses the selection to a single cell at the given coordinates.
*
* @example
* ```ts
* table_setFocusedCell(table, '3', 'firstName')
* ```
*/
function table_setFocusedCell(table, rowId, columnId) {
table_selectCellRange(table, {
anchorRowId: rowId,
anchorColumnId: columnId,
focusRowId: rowId,
focusColumnId: columnId
});
}
/**
* Selects a rectangle using replace, include, or exclude semantics.
*
* @example
* ```ts
* table_selectCellRange(table, range, { mode: 'exclude' })
* ```
*/
function table_selectCellRange(table, range, opts) {
const mode = opts?.mode ?? (opts?.additive ? "include" : "replace");
const { operation: _operation, ...coordinates } = range;
const nextRange = mode === "exclude" ? {
...coordinates,
operation: "exclude"
} : coordinates;
table_setCellSelection(table, (old) => mode === "replace" ? [nextRange] : [...old, nextRange]);
}
/**
* Returns the visible leaf columns that permit selection, in display order.
*
* A column-level opt-out is enough to exclude a column here; a per-cell
* predicate is not consulted, since navigation and select-all work in column
* space rather than cell space.
*/
function getSelectableColumns(table) {
const columns = getDisplayOrderedColumns(table);
if (table.options.enableCellSelection === false) return [];
return columns.filter((column) => column.columnDef.enableCellSelection !== false);
}
/**
* Selects every selectable cell in the table as one range.
*
* @example
* ```ts
* table_selectAllCells(table)
* ```
*/
function table_selectAllCells(table) {
const rows = table.getRowsInDisplayOrder();
const columns = getSelectableColumns(table);
if (!rows.length || !columns.length) return;
table_selectCellRange(table, {
anchorRowId: rows[0].id,
anchorColumnId: columns[0].id,
focusRowId: rows[rows.length - 1].id,
focusColumnId: columns[columns.length - 1].id
});
}
/**
* Resolves a direction into row and column deltas.
*/
function getDirectionDelta(direction) {
switch (direction) {
case "up": return {
rowDelta: -1,
columnDelta: 0
};
case "down": return {
rowDelta: 1,
columnDelta: 0
};
case "left": return {
rowDelta: 0,
columnDelta: -1
};
default: return {
rowDelta: 0,
columnDelta: 1
};
}
}
/**
* Steps one cell in a direction from a starting coordinate.
*
* Columns that cannot be selected are skipped over rather than landed on, so
* arrow navigation never parks on an opted-out column. Returns `null` when the
* step would leave the grid or find no selectable column.
*/
function stepCoordinate(table, rowId, columnId, direction) {
const rows = table.getRowModel().rows;
const columns = getDisplayOrderedColumns(table);
if (!rows.length || !columns.length) return null;
const { rowDelta, columnDelta } = getDirectionDelta(direction);
const rowIndex = rows.findIndex((row) => row.id === rowId);
const columnIndex = columns.findIndex((column) => column.id === columnId);
if (rowIndex < 0 || columnIndex < 0) return null;
const merges = callMemoOrStaticFn(table, "getCellSelectionMergeBounds", table_getCellSelectionMergeBounds);
let fromRowIndex = rows[rowIndex].getDisplayIndex();
let fromColumnIndex = columnIndex;
if (merges.length) {
const startMerge = findMergeBoundsAt(merges, rowIndex, columnIndex);
if (startMerge) {
if (rowDelta > 0) fromRowIndex = startMerge.maxRowIndex;
if (rowDelta < 0) fromRowIndex = startMerge.minRowIndex;
if (columnDelta > 0) fromColumnIndex = startMerge.maxColumnIndex;
if (columnDelta < 0) fromColumnIndex = startMerge.minColumnIndex;
}
}
let nextRowIndex = rowIndex + rowDelta;
if (rowDelta && fromRowIndex !== rows[rowIndex].getDisplayIndex()) {
const edgeRowIndex = rows.findIndex((row) => row.getDisplayIndex() === fromRowIndex);
if (edgeRowIndex < 0) return null;
nextRowIndex = edgeRowIndex + rowDelta;
}
if (nextRowIndex < 0 || nextRowIndex >= rows.length) return null;
const selectableColumnIds = new Set(getSelectableColumns(table).map((column) => column.id));
if (!selectableColumnIds.size) return null;
let nextColumnIndex = fromColumnIndex;
if (columnDelta) do
nextColumnIndex += columnDelta;
while (nextColumnIndex >= 0 && nextColumnIndex < columns.length && !selectableColumnIds.has(columns[nextColumnIndex].id));
else if (!selectableColumnIds.has(columnId)) for (let distance = 1; distance < columns.length; distance++) {
const before = columns[columnIndex - distance];
const after = columns[columnIndex + distance];
if (before && selectableColumnIds.has(before.id)) {
nextColumnIndex = columnIndex - distance;
break;
}
if (after && selectableColumnIds.has(after.id)) {
nextColumnIndex = columnIndex + distance;
break;
}
}
if (nextColumnIndex < 0 || nextColumnIndex >= columns.length || !selectableColumnIds.has(columns[nextColumnIndex].id)) return null;
let landingRowIndex = nextRowIndex;
let landingColumnIndex = nextColumnIndex;
if (merges.length) {
const landingMerge = findMergeBoundsAt(merges, rows[nextRowIndex].getDisplayIndex(), nextColumnIndex);
if (landingMerge) {
landingRowIndex = rows.findIndex((row) => row.getDisplayIndex() === landingMerge.minRowIndex);
if (landingRowIndex < 0) return null;
landingColumnIndex = landingMerge.minColumnIndex;
}
}
const landingRow = rows[landingRowIndex];
const landingColumn = columns[landingColumnIndex];
if (!landingRow || !landingColumn) return null;
return {
rowId: landingRow.id,
columnId: landingColumn.id
};
}
/**
* Moves the selection one step in a direction, collapsing it to a single cell.
*
* With nothing selected, this selects the first selectable cell so keyboard
* navigation has somewhere to start.
*
* @example
* ```ts
* table_moveCellSelection(table, 'down')
* ```
*/
function table_moveCellSelection(table, direction) {
const ranges = table.atoms.cellSelection?.get();
const active = ranges?.[ranges.length - 1];
if (!active) {
const rows = table.getRowModel().rows;
const columns = getSelectableColumns(table);
if (!rows.length || !columns.length) return;
table_setFocusedCell(table, rows[0].id, columns[0].id);
return;
}
const next = stepCoordinate(table, active.anchorRowId, active.anchorColumnId, direction);
if (!next) return;
table_setFocusedCell(table, next.rowId, next.columnId);
}
/**
* Extends the active range one step in a direction, keeping its anchor fixed.
*
* @example
* ```ts
* table_extendCellSelection(table, 'right')
* ```
*/
function table_extendCellSelection(table, direction) {
const ranges = table.atoms.cellSelection?.get();
const active = ranges?.[ranges.length - 1];
if (!active) {
table_moveCellSelection(table, direction);
return;
}
const next = stepCoordinate(table, active.focusRowId, active.focusColumnId, direction);
if (!next) return;
table_setCellSelection(table, (old) => {
if (!old.length) return old;
const nextRanges = old.slice(0, -1);
nextRanges.push({
...old[old.length - 1],
focusRowId: next.rowId,
focusColumnId: next.columnId
});
return nextRanges;
});
}
/**
* Walks each final positive region, invoking a visitor per selectable cell.
*
* Every expansion API shares this so the per-cell enable predicate is applied
* in exactly one place.
*/
function forEachSelectedCell(table, visit, skipCovered = false) {
const bounds = callMemoOrStaticFn(table, "getCellSelectionBounds", table_getCellSelectionBounds);
if (!bounds.length) return;
const rows = table.getRowsInDisplayOrder();
const columns = getDisplayOrderedColumns(table);
for (let i = 0; i < bounds.length; i++) {
const bound = bounds[i];
for (let rowIndex = bound.minRowIndex; rowIndex <= bound.maxRowIndex; rowIndex++) {
const row = rows[rowIndex];
if (!row) continue;
const cellsByColumnId = row.getAllCellsByColumnId();
for (let columnIndex = bound.minColumnIndex; columnIndex <= bound.maxColumnIndex; columnIndex++) {
const column = columns[columnIndex];
if (!column) continue;
const cell = cellsByColumnId[column.id];
if (!cell) continue;
if (!callMemoOrStaticFn(cell, "getCanSelect", cell_getCanSelect)) continue;
if (skipCovered && cell.getIsCovered?.()) continue;
visit(cell, i, rowIndex - bound.minRowIndex, columnIndex - bound.minColumnIndex);
}
}
}
}
/**
* Returns the ids of all selected cells, in row-major order.
*
* Cells covered by overlapping ranges are returned once, at their first
* occurrence.
*
* @example
* ```ts
* const ids = table_getSelectedCellIds(table)
* ```
*/
function table_getSelectedCellIds(table) {
const ids = [];
const seen = /* @__PURE__ */ new Set();
forEachSelectedCell(table, (cell) => {
if (seen.has(cell.id)) return;
seen.add(cell.id);
ids.push(cell.id);
}, true);
return ids;
}
/**
* Returns each final positive region's values as a row-major grid.
*
* This is the raw material for clipboard export. Serializing it to text is left
* to userland, since the delimiter, the null representation, and whether values
* containing delimiters get quoted are all application decisions.
*
* @example
* ```ts
* const [firstRange] = table_getSelectedCellRangesData(table)
* ```
*/
function table_getSelectedCellRangesData(table) {
const grids = [];
forEachSelectedCell(table, (cell, rangeIndex, rowOffset) => {
const grid = grids[rangeIndex] ??= [];
(grid[rowOffset] ??= []).push(cell.getValue());
});
return grids;
}
/**
* Returns the number of selected cells.
*
* Uses rectangle arithmetic over the normalized, disjoint positive regions.
* A per-cell `enableCellSelection` predicate requires enumeration.
*
* @example
* ```ts
* const count = table_getSelectedCellCount(table)
* ```
*/
function table_getSelectedCellCount(table) {
if (table.options.enableCellSelection === false) return 0;
const bounds = callMemoOrStaticFn(table, "getCellSelectionBounds", table_getCellSelectionBounds);
if (!bounds.length) return 0;
const merges = callMemoOrStaticFn(table, "getCellSelectionMergeBounds", table_getCellSelectionMergeBounds);
if (typeof table.options.enableCellSelection === "function" || merges.length) {
const ids = /* @__PURE__ */ new Set();
forEachSelectedCell(table, (cell) => ids.add(cell.id), true);
return ids.size;
}
const columns = getDisplayOrderedColumns(table);
let count = 0;
for (const bound of bounds) {
let selectableColumns = 0;
for (let columnIndex = bound.minColumnIndex; columnIndex <= bound.maxColumnIndex; columnIndex++) {
const column = columns[columnIndex];
if (!column) continue;
if (column.columnDef.enableCellSelection !== false) selectableColumns++;
}
count += (bound.maxRowIndex - bound.minRowIndex + 1) * selectableColumns;
}
return count;
}
/**
* Returns the ids of all rows intersected by the selection.
*
* @example
* ```ts
* const rowIds = table_getCellSelectionRowIds(table)
* ```
*/
function table_getCellSelectionRowIds(table) {
const bounds = callMemoOrStaticFn(table, "getCellSelectionBounds", table_getCellSelectionBounds);
if (!bounds.length) return [];
const rows = table.getRowsInDisplayOrder();
const seen = /* @__PURE__ */ new Set();
const ids = [];
for (let i = 0; i < bounds.length; i++) {
const bound = bounds[i];
for (let index = bound.minRowIndex; index <= bound.maxRowIndex; index++) {
const row = rows[index];
if (!row || seen.has(row.id)) continue;
seen.add(row.id);
ids.push(row.id);
}
}
return ids;
}
/**
* Returns the ids of all columns intersected by the selection.
*
* @example
* ```ts
* const columnIds = table_getCellSelectionColumnIds(table)
* ```
*/
function table_getCellSelectionColumnIds(table) {
const bounds = callMemoOrStaticFn(table, "getCellSelectionBounds", table_getCellSelectionBounds);
if (!bounds.length) return [];
const columns = getDisplayOrderedColumns(table);
const seen = /* @__PURE__ */ new Set();
const ids = [];
for (let i = 0; i < bounds.length; i++) {
const bound = bounds[i];
for (let index = bound.minColumnIndex; index <= bound.maxColumnIndex; index++) {
const column = columns[index];
if (!column || seen.has(column.id)) continue;
if (column.columnDef.enableCellSelection === false) continue;
seen.add(column.id);
ids.push(column.id);
}
}
return ids;
}
/**
* Creates a handler that begins a selection at this cell.
*
* Follows `header_getResizeHandler`: the enable check is resolved once outside
* the returned closure and guarded again inside it, the document is injectable
* for SSR and cross-document rendering, and the document-level `mouseup`
* listener is attached here so a drag released outside the table still ends.
*
* @example
* ```tsx
* <td onMouseDown={cell.getSelectionStartHandler()} />
* ```
*/
function cell_getSelectionStartHandler(cell, _contextDocument) {
const canSelect = cell_getCanSelect(cell);
return (e) => {
if (!canSelect) return;
const table = cell.table;
const options = table.options;
const contextDocument = _contextDocument ?? (typeof document !== "undefined" ? document : null);
const isRangeEvent = options.enableCellRangeSelection !== false && (options.isCellRangeSelectionEvent?.(e) ?? false);
const isMultiRangeEvent = options.enableMultiCellRangeSelection !== false && (options.isMultiCellRangeSelectionEvent?.(e) ?? false);
if (options.enableCellSelectionDrag !== false && options.enableCellRangeSelection !== false && contextDocument) {
table._isSelectingCells = true;
const upHandler = () => {
contextDocument.removeEventListener("mouseup", upHandler);
table._isSelectingCells = false;
};
contextDocument.addEventListener("mouseup", upHandler);
}
const rowId = cell.row.id;
const columnId = cell.column.id;
const shouldExclude = isMultiRangeEvent && callMemoOrStaticFn(cell, "getIsSelected", cell_getIsSelected);
table_setCellSelection(table, (old) => {
const active = old[old.length - 1];
if (isRangeEvent && active) {
const ranges = old.slice(0, -1);
ranges.push({
...active,
focusRowId: rowId,
focusColumnId: columnId
});
return ranges;
}
const range = {
anchorRowId: rowId,
anchorColumnId: columnId,
focusRowId: rowId,
focusColumnId: columnId,
...shouldExclude ? { operation: "exclude" } : {}
};
return isMultiRangeEvent ? [...old, range] : [range];
});
};
}
/**
* Creates a handler that extends the active range to this cell during a drag.
*
* No rAF coalescing is needed here, unlike the resize handler: `mouseenter`
* fires once per cell boundary crossed rather than continuously, and deferring
* it by a frame would only delay the highlight.
*
* @example
* ```tsx
* <td onMouseEnter={cell.getSelectionExtendHandler()} />
* ```
*/
function cell_getSelectionExtendHandler(cell) {
const canSelect = cell_getCanSelect(cell);
return (_e) => {
if (!canSelect) return;
const table = cell.table;
if (!table._isSelectingCells) return;
const ranges = table.atoms.cellSelection?.get();
const active = ranges?.[ranges.length - 1];
if (!active) return;
const rowId = cell.row.id;
const columnId = cell.column.id;
if (active.focusRowId === rowId && active.focusColumnId === columnId) return;
table_setCellSelection(table, (old) => {
if (!old.length) return old;
const next = old.slice(0, -1);
next.push({
...old[old.length - 1],
focusRowId: rowId,
focusColumnId: columnId
});
return next;
});
};
}
//#endregion
export { cell_getCanSelect, cell_getIsFocused, cell_getIsSelected, cell_getSelectionEdges, cell_getSelectionExtendHandler, cell_getSelectionStartHandler, cell_getTabIndex, getDefaultCellSelectionState, table_autoResetCellSelection, table_extendCellSelection, table_getCellSelectionBounds, table_getCellSelectionColumnIds, table_getCellSelectionColumnIndexes, table_getCellSelectionMergeBounds, table_getCellSelectionRowIds, table_getFocusedCell, table_getSelectedCellCount, table_getSelectedCellIds, table_getSelectedCellRangesData, table_moveCellSelection, table_resetCellSelection, table_selectAllCells, table_selectCellRange, table_setCellSelection, table_setFocusedCell };