@ackplus/react-tanstack-data-table
Version:
A powerful React data table component built with MUI and TanStack Table
319 lines (279 loc) • 11.4 kB
text/typescript
/**
* Custom Selection Feature for TanStack Table
*
* This feature adds custom selection capabilities to TanStack Table
* following the official custom features pattern (same as CustomColumnFilterFeature)
*/
import {
Table,
TableFeature,
RowData,
Updater,
functionalUpdate,
makeStateUpdater,
Row,
} from '@tanstack/react-table';
// Selection state interface
export interface SelectionState {
ids: string[];
type: 'include' | 'exclude';
selectMode?: 'page' | 'all';
}
// Row selectability function type (like MUI DataGrid)
export type IsRowSelectableFunction<T = any> = (params: { row: T; id: string }) => boolean;
// Selection mode type
export type SelectMode = 'page' | 'all';
// Options for the custom selection feature
export interface SelectionOptions {
enableAdvanceSelection?: boolean;
selectMode?: SelectMode;
isRowSelectable?: IsRowSelectableFunction;
onSelectionStateChange?: (updater: Updater<SelectionState>) => void;
}
// Table state interface for selection
export interface SelectionTableState {
selectionState: SelectionState;
}
// Declaration merging to extend TanStack Table types
declare module '@tanstack/react-table' {
interface TableState extends SelectionTableState { }
interface TableOptionsResolved<TData extends RowData>
extends SelectionOptions { }
interface Table<TData extends RowData> extends SelectionInstance<TData> { }
}
// Table instance methods for custom selection
export interface SelectionInstance<TData extends RowData> {
// Basic selection methods
setSelectionState: (updater: Updater<SelectionState>) => void;
toggleAllRowsSelected: () => void;
toggleRowSelected: (rowId: string) => void;
selectRow: (rowId: string) => void;
deselectRow: (rowId: string) => void;
selectAll: () => void;
deselectAll: () => void;
// State checkers
getIsAllRowsSelected: () => boolean;
getIsSomeRowsSelected: () => boolean;
getIsRowSelected: (rowId: string) => boolean;
// Getters
getSelectionState: () => SelectionState;
getSelectedCount: () => number;
getSelectedRows: () => Row<TData>[];
getSelectedRowIds: () => string[];
// Helper methods
canSelectRow: (rowId: string) => boolean;
}
// The custom selection feature implementation (same pattern as CustomColumnFilterFeature)
export const SelectionFeature: TableFeature<any> = {
// Define the feature's initial state
getInitialState: (state): SelectionTableState => {
return {
selectionState: {
ids: [],
type: 'include',
selectMode: 'page',
},
...state,
};
},
// Define the feature's default options
getDefaultOptions: <TData extends RowData>(
table: Table<TData>
): SelectionOptions => {
return {
enableAdvanceSelection: true,
selectMode: 'page',
onSelectionStateChange: makeStateUpdater('selectionState', table),
} as SelectionOptions;
},
// Define the feature's table instance methods
createTable: <TData extends RowData>(table: Table<TData>): void => {
table.setSelectionState = (updater) => {
if (!table.options.enableAdvanceSelection) return;
const safeUpdater: Updater<SelectionState> = (old) => {
const newState = functionalUpdate(updater, old);
return newState;
};
return table.options.onSelectionStateChange?.(safeUpdater);
};
// === BASIC SELECTION METHODS ===
table.selectRow = (rowId: string) => {
if (!table.options.enableAdvanceSelection) return;
if (!table.canSelectRow(rowId)) return;
table.setSelectionState((old) => {
if (old.type === 'exclude') {
// In exclude mode, selecting means removing from exclude list
return {
...old,
ids: old.ids.filter(id => id !== rowId),
};
} else {
// In include mode, selecting means adding to include list
const newIds = old.ids.includes(rowId) ? old.ids : [...old.ids, rowId];
return {
...old,
ids: newIds,
};
}
});
};
table.deselectRow = (rowId: string) => {
if (!table.options.enableAdvanceSelection) return;
table.setSelectionState((old) => {
if (old.type === 'exclude') {
// In exclude mode, deselecting means adding to exclude list
const newIds = old.ids.includes(rowId) ? old.ids : [...old.ids, rowId];
return {
...old,
ids: newIds,
};
} else {
// In include mode, deselecting means removing from include list
return {
...old,
ids: old.ids.filter(id => id !== rowId),
};
}
});
};
table.toggleRowSelected = (rowId: string) => {
if (!table.options.enableAdvanceSelection) return;
if (table.getIsRowSelected(rowId)) {
table.deselectRow(rowId);
} else {
table.selectRow(rowId);
}
};
table.selectAll = () => {
if (!table.options.enableAdvanceSelection) return;
const selectMode = table.options.selectMode || 'page';
const currentRows =
table.getPaginationRowModel?.()?.rows ||
table.getRowModel().rows;
if (selectMode === 'all') {
// In 'all' mode, use exclude type with empty list (select all)
table.setSelectionState((old) => ({
...old,
ids: [],
type: 'exclude',
}));
} else {
// In 'page' mode, select current page rows
const selectableRowIds = currentRows
.filter(row => table.canSelectRow(row.id))
.map(row => row.id);
table.setSelectionState((old) => ({
...old,
ids: selectableRowIds,
type: 'include',
}));
}
};
table.deselectAll = () => {
if (!table.options.enableAdvanceSelection) return;
table.setSelectionState((old) => ({
...old,
ids: [],
type: 'include',
}));
};
table.toggleAllRowsSelected = () => {
if (!table.options.enableAdvanceSelection) return;
if (table.getIsAllRowsSelected()) {
table.deselectAll();
} else {
table.selectAll();
}
};
// === STATE CHECKERS ===
table.getIsRowSelected = (rowId: string) => {
const state = table.getSelectionState();
if (state.type === 'exclude') {
// In exclude mode, selected if NOT in exclude list
return !state.ids.includes(rowId);
} else {
// In include mode, selected if in include list
return state.ids.includes(rowId);
}
};
table.getIsAllRowsSelected = () => {
const state = table.getSelectionState();
const selectMode = table.options.selectMode || 'page';
if (selectMode === 'all') {
const totalCount = table.getRowCount();
if (totalCount === 0) return false;
if (state.type === 'exclude') {
return state.ids.length === 0;
} else {
return state.ids.length === totalCount;
}
} else {
// Page mode - check if all selectable rows on current page are selected
const currentPageRows = table.getPaginationRowModel?.()?.rows || table.getRowModel().rows;
const selectableRows = currentPageRows.filter(row => table.canSelectRow(row.id));
if (selectableRows.length === 0) return false;
return selectableRows.every(row => table.getIsRowSelected(row.id));
}
};
table.getIsSomeRowsSelected = () => {
const state = table.getSelectionState();
const selectMode = table.options.selectMode || 'page';
if (selectMode === 'all' && state.type === 'exclude') {
// In exclude mode, we have some selected if not all are excluded
const totalCount = table.getRowCount();
return totalCount > 0 && state.ids.length < totalCount;
} else {
// In include mode, we have some selected if list has items
return state.ids.length > 0;
}
};
// === GETTERS ===
table.getSelectionState = () => {
return table.getState().selectionState || {
ids: [],
type: 'include',
selectMode: 'page',
};
};
table.getSelectedCount = () => {
const state = table.getSelectionState();
const selectMode = table.options.selectMode || 'page';
if (selectMode === 'all' && state.type === 'exclude') {
// For server-side data, use rowCount which includes total from server
// For client-side data, this will be the same as getRowModel().rows.length
const totalCount = table.getRowCount();
return Math.max(0, totalCount - state.ids.length);
} else {
return state.ids.length;
}
};
table.getSelectedRowIds = () => {
const state = table.getSelectionState();
if (state.type === 'exclude') {
console.warn(
'[SelectionFeature] getSelectedRowIds() is not accurate in exclude mode. Use getSelectionState() to interpret selection properly.'
);
return []; // Return empty to avoid misleading API
}
return state.ids;
};
table.getSelectedRows = () => {
const state = table.getSelectionState();
const allRows = table.getRowModel().rows;
if (state.type === 'exclude') {
// Return all rows except excluded ones
return allRows.filter(row => !state.ids.includes(row.id))
} else {
// Return only included rows
return allRows.filter(row => state.ids.includes(row.id))
}
};
// === HELPER METHODS ===
table.canSelectRow = (rowId: string) => {
if (!table.options.isRowSelectable) return true;
const row = table.getRowModel().rows.find(r => r.id === rowId);
if (!row) return false;
return table.options.isRowSelectable({ row: row.original, id: rowId });
};
},
};