From 772e13b4e9c8b20ff34baecfadbec246f1e90fe5 Mon Sep 17 00:00:00 2001 From: lukasbash Date: Wed, 24 Jun 2026 19:17:21 +0200 Subject: [PATCH 1/2] feat: add inline cell editing Mark any column as editable and provide an onCellEdit handler to enable double-click inline editing. A plain unstyled is used as the built-in fallback; columns can supply an editRender function to render any custom control (e.g. a Select dropdown). Per-column options: - editable: true | (record, index) => boolean - editRender: (record, index, context) => ReactNode - commitEditOn: ('blur' | 'enter')[] (default: both) Table-level options: - onCellEdit: ({ record, index, accessor, value }) => void - defaultCommitEditOn: ('blur' | 'enter')[] - editMode: 'cell' (default) | 'global' - editingCellStyle / editingCellClassName: style the focused cell's In global mode all editable cells are in edit state at once; Tab / Shift+Tab navigate between them. Existing callbacks continue to fire. --- package/DataTable.tsx | 202 +++++++++++++++++++- package/DataTableRow.tsx | 144 +++++++++++++- package/DataTableRowCell.tsx | 126 ++++++++++-- package/types/DataTableColumn.ts | 22 +++ package/types/DataTableEditRenderContext.ts | 8 + package/types/DataTableProps.ts | 36 ++++ package/types/index.ts | 1 + 7 files changed, 520 insertions(+), 19 deletions(-) create mode 100644 package/types/DataTableEditRenderContext.ts diff --git a/package/DataTable.tsx b/package/DataTable.tsx index facdd37a..466ce20f 100644 --- a/package/DataTable.tsx +++ b/package/DataTable.tsx @@ -2,7 +2,7 @@ import { Box, type MantineSize, Table } from '@mantine/core'; import { useMergedRef } from '@mantine/hooks'; import clsx from 'clsx'; import type { RefObject } from 'react'; -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react'; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { getTableCssVariables } from './cssVariables'; import { DataTableColumnsProvider } from './DataTableDragToggleProvider'; import { DataTableEmptyRow } from './DataTableEmptyRow'; @@ -22,7 +22,7 @@ import { } from './hooks'; import type { DataTableProps } from './types'; import { TEXT_SELECTION_DISABLED } from './utilityClasses'; -import { differenceBy, flattenColumns, getRecordId, uniqBy } from './utils'; +import { differenceBy, flattenColumns, getRecordId, getValueAtPath, uniqBy } from './utils'; export function DataTable({ withTableBorder, @@ -98,6 +98,11 @@ export function DataTable({ onCellClick, onCellDoubleClick, onCellContextMenu, + onCellEdit, + defaultCommitEditOn = ['blur', 'enter'], + editMode = 'cell', + editingCellStyle, + editingCellClassName, onScroll, onScrollToTop, onScrollToBottom, @@ -166,6 +171,179 @@ export function DataTable({ const mergedBodyRef = useMergedRef(internalBodyRef, bodyRef); const rowExpansionInfo = useRowExpansion({ rowExpansion, records, idAccessor }); + const [editingCell, setEditingCell] = useState<{ + recordKey: React.Key; + accessor: string; + value: string; + } | null>(null); + + const [globalEditValues, setGlobalEditValues] = useState>(new Map()); + const [focusedCell, setFocusedCell] = useState<{ recordKey: string; accessor: string } | null>(null); + const cellInputRefs = useRef>(new Map()); + + // Initialize globalEditValues when switching to global mode or when records change + useEffect(() => { + if (editMode !== 'global') { + setGlobalEditValues(new Map()); + setFocusedCell(null); + return; + } + if (!records) return; + setGlobalEditValues((prev) => { + const next = new Map(prev); + let changed = false; + for (let i = 0; i < records.length; i++) { + const record = records[i]; + const recordKey = String(getRecordId(record, idAccessor)); + for (const col of effectiveColumns) { + if (col.hidden || col.hiddenContent) continue; + const { accessor, editable } = defaultColumnProps ? { ...defaultColumnProps, ...col } : col; + const isEditable = + editable === true || (typeof editable === 'function' && editable(record, i)); + if (isEditable) { + const mapKey = `${recordKey}::${String(accessor)}`; + if (!next.has(mapKey)) { + next.set(mapKey, String(getValueAtPath(record, accessor) ?? '')); + changed = true; + } + } + } + } + return changed ? next : prev; + }); + }, [editMode, records, idAccessor, effectiveColumns, defaultColumnProps]); + + const handleEditStart = useCallback((recordKey: React.Key, accessor: string, value: string) => { + setEditingCell({ recordKey, accessor, value }); + }, []); + + const handleEditChange = useCallback((value: string) => { + setEditingCell((prev) => (prev ? { ...prev, value } : null)); + }, []); + + const handleEditCommit = useCallback( + (overrideValue?: string) => { + if (editingCell) { + if (onCellEdit) { + const finalValue = overrideValue !== undefined ? overrideValue : editingCell.value; + const record = records?.find((r) => getRecordId(r, idAccessor) === editingCell.recordKey); + if (record) { + const index = records!.indexOf(record); + onCellEdit({ + record, + index, + accessor: editingCell.accessor as keyof T | (string & NonNullable), + value: finalValue, + }); + } + } + setEditingCell(null); + } + }, + [editingCell, onCellEdit, records, idAccessor] + ); + + const handleEditCancel = useCallback(() => { + setEditingCell(null); + }, []); + + const handleGlobalEditChange = useCallback((recordKey: React.Key, accessor: string, value: string) => { + setGlobalEditValues((prev) => { + const next = new Map(prev); + next.set(`${String(recordKey)}::${accessor}`, value); + return next; + }); + }, []); + + const handleGlobalEditCommit = useCallback( + (recordKey: React.Key, accessor: string, overrideValue?: string) => { + if (onCellEdit) { + const mapKey = `${String(recordKey)}::${accessor}`; + const finalValue = overrideValue !== undefined ? overrideValue : (globalEditValues.get(mapKey) ?? ''); + const record = records?.find((r) => getRecordId(r, idAccessor) === recordKey); + if (record) { + const index = records!.indexOf(record); + onCellEdit({ + record, + index, + accessor: accessor as keyof T | (string & NonNullable), + value: finalValue, + }); + } + } + // In global mode, cell stays in edit state — do not clear editingCell + }, + [onCellEdit, globalEditValues, records, idAccessor] + ); + + const handleGlobalEditCancel = useCallback( + (recordKey: React.Key, accessor: string) => { + const record = records?.find((r) => String(getRecordId(r, idAccessor)) === String(recordKey)); + if (record) { + const original = String(getValueAtPath(record, accessor) ?? ''); + setGlobalEditValues((prev) => { + const next = new Map(prev); + next.set(`${String(recordKey)}::${accessor}`, original); + return next; + }); + } + }, + [records, idAccessor] + ); + + const handleCellFocus = useCallback((recordKey: React.Key, accessor: string) => { + setFocusedCell({ recordKey: String(recordKey), accessor }); + }, []); + + const handleCellBlur = useCallback(() => { + setFocusedCell(null); + }, []); + + const registerCellRef = useCallback((recordKey: React.Key, accessor: string, el: HTMLInputElement | null) => { + const key = `${String(recordKey)}::${accessor}`; + if (el) { + cellInputRefs.current.set(key, el); + } else { + cellInputRefs.current.delete(key); + } + }, []); + + const handleFocusCell = useCallback((recordKey: React.Key, accessor: string) => { + const key = `${String(recordKey)}::${accessor}`; + cellInputRefs.current.get(key)?.focus(); + }, []); + + const handleFocusNextRow = useCallback( + (direction: 'next' | 'prev', fromRecordId: React.Key) => { + if (!records) return; + const currentIdx = records.findIndex((r) => getRecordId(r, idAccessor) === fromRecordId); + if (currentIdx === -1) return; + const rawNext = direction === 'next' ? currentIdx + 1 : currentIdx - 1; + const nextIdx = ((rawNext % records.length) + records.length) % records.length; + + const nextRecord = records[nextIdx]; + const nextRecordKey = getRecordId(nextRecord, idAccessor) as React.Key; + const editableCols = effectiveColumns + .filter(({ hidden, hiddenContent }) => !hidden && !hiddenContent) + .map((col) => (defaultColumnProps ? { ...defaultColumnProps, ...col } : col)) + .filter( + ({ editable }) => + editable === true || (typeof editable === 'function' && editable(nextRecord, nextIdx)) + ); + if (!editableCols.length) return; + + const targetCol = direction === 'next' ? editableCols[0] : editableCols[editableCols.length - 1]; + const targetAccessor = String(targetCol.accessor); + + if (editMode === 'global') { + handleFocusCell(nextRecordKey, targetAccessor); + } else { + handleEditStart(nextRecordKey, targetAccessor, String(getValueAtPath(nextRecord, targetCol.accessor) ?? '')); + } + }, + [records, idAccessor, effectiveColumns, defaultColumnProps, editMode, handleFocusCell, handleEditStart] + ); + const { pinnedMap, hasLeftPinned, hasRightPinned } = useDataTablePinnedColumns({ columns: effectiveColumns, theadRef: refs.header as RefObject, @@ -428,6 +606,26 @@ export function DataTable({ selectionColumnStyle={selectionColumnStyle} idAccessor={idAccessor as string} rowFactory={rowFactory} + recordId={recordId as React.Key} + editingCell={editingCell} + onEditStart={handleEditStart} + onEditChange={handleEditChange} + onEditCommit={handleEditCommit} + onEditCancel={handleEditCancel} + defaultCommitEditOn={defaultCommitEditOn} + editMode={editMode} + globalEditValues={globalEditValues} + focusedCell={focusedCell} + onCellFocus={handleCellFocus} + onCellBlur={handleCellBlur} + onGlobalEditChange={handleGlobalEditChange} + onGlobalEditCommit={handleGlobalEditCommit} + onGlobalEditCancel={handleGlobalEditCancel} + registerCellRef={registerCellRef} + onFocusCell={handleFocusCell} + onFocusNextRow={handleFocusNextRow} + editingCellStyle={editingCellStyle} + editingCellClassName={editingCellClassName} /> ); }) diff --git a/package/DataTableRow.tsx b/package/DataTableRow.tsx index 780994dc..12d32fcd 100644 --- a/package/DataTableRow.tsx +++ b/package/DataTableRow.tsx @@ -1,5 +1,5 @@ -import type { MantineTheme } from '@mantine/core'; -import { type CheckboxProps, type MantineColor, type MantineStyleProp, TableTr } from '@mantine/core'; +import type { MantineStyleProp, MantineTheme } from '@mantine/core'; +import { type CheckboxProps, type MantineColor, TableTr } from '@mantine/core'; import clsx from 'clsx'; import { getRowCssVariables } from './cssVariables'; import { DataTableRowCell } from './DataTableRowCell'; @@ -15,6 +15,7 @@ import type { DataTableSelectionTrigger, } from './types'; import { CONTEXT_MENU_CURSOR, POINTER_CURSOR } from './utilityClasses'; +import { getValueAtPath } from './utils'; type DataTableRowProps = { record: T; @@ -52,6 +53,26 @@ type DataTableRowProps = { selectionColumnClassName: string | undefined; selectionColumnStyle: MantineStyleProp | undefined; idAccessor: string; + recordId: React.Key; + editingCell: { recordKey: React.Key; accessor: string; value: string } | null; + onEditStart: (recordKey: React.Key, accessor: string, value: string) => void; + onEditChange: (value: string) => void; + onEditCommit: (value?: string) => void; + onEditCancel: () => void; + defaultCommitEditOn: ('blur' | 'enter')[]; + editMode: 'cell' | 'global'; + globalEditValues: Map; + focusedCell: { recordKey: string; accessor: string } | null; + onCellFocus: (recordKey: React.Key, accessor: string) => void; + onCellBlur: () => void; + onGlobalEditChange: (recordKey: React.Key, accessor: string, value: string) => void; + onGlobalEditCommit: (recordKey: React.Key, accessor: string, overrideValue?: string) => void; + onGlobalEditCancel: (recordKey: React.Key, accessor: string) => void; + registerCellRef: (recordKey: React.Key, accessor: string, el: HTMLInputElement | null) => void; + onFocusCell: (recordKey: React.Key, accessor: string) => void; + onFocusNextRow: (direction: 'next' | 'prev', fromRecordId: React.Key) => void; + editingCellStyle?: MantineStyleProp; + editingCellClassName?: string; } & Pick, 'rowFactory'>; export function DataTableRow({ @@ -84,7 +105,32 @@ export function DataTableRow({ selectionColumnClassName, selectionColumnStyle, rowFactory, + recordId, + editingCell, + onEditStart, + onEditChange, + onEditCommit, + onEditCancel, + defaultCommitEditOn, + editMode, + globalEditValues, + focusedCell, + onCellFocus, + onCellBlur, + onGlobalEditChange, + onGlobalEditCommit, + onGlobalEditCancel, + registerCellRef, + onFocusCell, + onFocusNextRow, + editingCellStyle, + editingCellClassName, }: Readonly>) { + const editableAccessors = columns + .filter(({ hidden, hiddenContent }) => !hidden && !hiddenContent) + .map((col) => ({ ...defaultColumnProps, ...col })) + .filter(({ editable }) => editable === true || (typeof editable === 'function' && editable(record, index))) + .map(({ accessor }) => String(accessor)); const cols = ( <> {selectionVisible && ( @@ -117,8 +163,71 @@ export function DataTableRow({ cellsClassName, cellsStyle, customCellAttributes, + editable, + editRender, + commitEditOn, } = { ...defaultColumnProps, ...columnProps }; + const isEditableCell = + editable === true || (typeof editable === 'function' && editable(record, index)); + const cellKey = `${String(recordId)}::${String(accessor)}`; + const isEditing = + editMode === 'global' + ? isEditableCell + : isEditableCell && + editingCell?.recordKey === recordId && + editingCell?.accessor === String(accessor); + const editValue = + editMode === 'global' + ? (globalEditValues.get(cellKey) ?? '') + : isEditing + ? (editingCell?.value ?? '') + : ''; + const cellCommitEditOn = commitEditOn ?? defaultCommitEditOn; + const isFocused = + focusedCell?.recordKey === String(recordId) && focusedCell?.accessor === String(accessor); + + const cellOnEditChange = + editMode === 'global' + ? (value: string) => onGlobalEditChange(recordId, String(accessor), value) + : onEditChange; + const cellOnEditCommit = + editMode === 'global' + ? (overrideValue?: string) => onGlobalEditCommit(recordId, String(accessor), overrideValue) + : onEditCommit; + const cellOnEditCancel = + editMode === 'global' + ? () => onGlobalEditCancel(recordId, String(accessor)) + : onEditCancel; + const cellRegisterInputRef = isEditableCell + ? (el: HTMLInputElement | null) => registerCellRef(recordId, String(accessor), el) + : undefined; + const cellOnCellFocus = isEditableCell + ? () => onCellFocus(recordId, String(accessor)) + : undefined; + const cellIdx = isEditableCell ? editableAccessors.indexOf(String(accessor)) : -1; + const cellOnFocusNextCell = isEditableCell + ? (direction: 'next' | 'prev') => { + const targetIdx = direction === 'next' ? cellIdx + 1 : cellIdx - 1; + if (targetIdx >= 0 && targetIdx < editableAccessors.length) { + const targetAccessor = editableAccessors[targetIdx]; + if (editMode === 'global') { + onFocusCell(recordId, targetAccessor); + } else { + const targetCol = columns.find( + (c) => String(({ ...defaultColumnProps, ...c }).accessor) === targetAccessor + ); + if (targetCol) { + const { accessor: tAcc } = { ...defaultColumnProps, ...targetCol }; + onEditStart(recordId, targetAccessor, String(getValueAtPath(record, tAcc) ?? '')); + } + } + } else { + onFocusNextRow(direction, recordId); + } + } + : undefined; + return ( key={accessor as React.Key} @@ -134,9 +243,20 @@ export function DataTableRow({ : undefined } onDoubleClick={ - onCellDoubleClick - ? (event) => onCellDoubleClick({ event, record, index, column: columnProps, columnIndex }) - : undefined + isEditableCell && editMode === 'cell' + ? (event) => { + if (!isEditing) { + onEditStart( + recordId, + String(accessor), + String(getValueAtPath(record, accessor) ?? '') + ); + } + onCellDoubleClick?.({ event, record, index, column: columnProps, columnIndex }); + } + : onCellDoubleClick + ? (event) => onCellDoubleClick({ event, record, index, column: columnProps, columnIndex }) + : undefined } onContextMenu={ onCellContextMenu @@ -151,6 +271,20 @@ export function DataTableRow({ render={render} defaultRender={defaultColumnRender} customCellAttributes={customCellAttributes} + isEditing={!!isEditing} + editValue={editValue} + onEditChange={cellOnEditChange} + onEditCommit={cellOnEditCommit} + onEditCancel={cellOnEditCancel} + commitEditOn={cellCommitEditOn} + editRender={editRender} + isFocused={isFocused} + onCellFocus={cellOnCellFocus} + onCellBlur={onCellBlur} + registerInputRef={cellRegisterInputRef} + onFocusNextCell={cellOnFocusNextCell} + editingCellStyle={editingCellStyle} + editingCellClassName={editingCellClassName} /> ); })} diff --git a/package/DataTableRowCell.tsx b/package/DataTableRowCell.tsx index 9d3bf37a..21c14cfa 100644 --- a/package/DataTableRowCell.tsx +++ b/package/DataTableRowCell.tsx @@ -1,5 +1,6 @@ import { type MantineStyleProp, TableTd } from '@mantine/core'; import clsx from 'clsx'; +import { useCallback, useLayoutEffect, useRef } from 'react'; import type { PinnedColumnInfo } from './hooks'; import { useMediaQueryStringOrFunction } from './hooks'; import type { DataTableColumn } from './types'; @@ -26,9 +27,30 @@ type DataTableRowCellProps = { onClick: React.MouseEventHandler | undefined; onDoubleClick: React.MouseEventHandler | undefined; onContextMenu: React.MouseEventHandler | undefined; + isEditing: boolean; + editValue: string; + onEditChange: (value: string) => void; + onEditCommit: (value?: string) => void; + onEditCancel: () => void; + commitEditOn: ('blur' | 'enter')[]; + isFocused: boolean; + onCellFocus?: () => void; + onCellBlur?: () => void; + registerInputRef?: (el: HTMLInputElement | null) => void; + onFocusNextCell?: (direction: 'next' | 'prev') => void; + editingCellStyle?: MantineStyleProp; + editingCellClassName?: string; } & Pick< DataTableColumn, - 'accessor' | 'visibleMediaQuery' | 'textAlign' | 'width' | 'noWrap' | 'ellipsis' | 'render' | 'customCellAttributes' + | 'accessor' + | 'visibleMediaQuery' + | 'textAlign' + | 'width' + | 'noWrap' + | 'ellipsis' + | 'render' + | 'customCellAttributes' + | 'editRender' >; export function DataTableRowCell({ @@ -49,7 +71,35 @@ export function DataTableRowCell({ render, defaultRender, customCellAttributes, + isEditing, + editValue, + onEditChange, + onEditCommit, + onEditCancel, + commitEditOn, + editRender, + isFocused, + onCellFocus, + onCellBlur, + registerInputRef, + onFocusNextCell, + editingCellStyle, + editingCellClassName, }: DataTableRowCellProps) { + const committingRef = useRef(false); + const inputRef = useRef(null); + // Store latest registerInputRef in a ref so the stable callback always calls it + const registerInputRefRef = useRef(registerInputRef); + registerInputRefRef.current = registerInputRef; + const stableInputRef = useCallback((el: HTMLInputElement | null) => { + inputRef.current = el; + registerInputRefRef.current?.(el); + }, []); + + useLayoutEffect(() => { + if (isEditing && !editRender) inputRef.current?.focus(); + }, [isEditing, editRender]); + if (!useMediaQueryStringOrFunction(visibleMediaQuery)) return null; return ( ({ { [NOWRAP]: noWrap || ellipsis, [ELLIPSIS]: ellipsis, - [POINTER_CURSOR]: onClick || onDoubleClick, - [CONTEXT_MENU_CURSOR]: onContextMenu, + [POINTER_CURSOR]: !isEditing && (onClick || onDoubleClick), + [CONTEXT_MENU_CURSOR]: !isEditing && onContextMenu, [TEXT_ALIGN_LEFT]: textAlign === 'left', [TEXT_ALIGN_CENTER]: textAlign === 'center', [TEXT_ALIGN_RIGHT]: textAlign === 'right', }, - className + className, + isEditing && isFocused ? editingCellClassName : undefined )} style={[ { @@ -79,17 +130,68 @@ export function DataTableRowCell({ [pinnedInfo.position]: pinnedInfo.offset, overflow: 'visible', }, + isEditing && isFocused ? editingCellStyle : undefined, ]} - onClick={onClick} - onDoubleClick={onDoubleClick} - onContextMenu={onContextMenu} + onClick={isEditing ? undefined : onClick} + onDoubleClick={isEditing ? undefined : onDoubleClick} + onContextMenu={isEditing ? undefined : onContextMenu} {...customCellAttributes?.(record, index)} > - {render - ? render(record, index) - : defaultRender - ? defaultRender(record, index, accessor) - : (getValueAtPath(record, accessor) as React.ReactNode)} + {isEditing ? ( + editRender ? ( + editRender(record, index, { + value: editValue, + onChange: onEditChange, + onCommit: onEditCommit, + onCancel: onEditCancel, + }) + ) : ( + onEditChange(e.target.value)} + onFocus={onCellFocus} + onKeyDown={(e) => { + if (commitEditOn.includes('enter') && e.key === 'Enter') { + committingRef.current = true; + onEditCommit(); + } + if (e.key === 'Escape') onEditCancel(); + if (e.key === 'Tab' && onFocusNextCell) { + e.preventDefault(); + committingRef.current = true; + onEditCommit(); + onFocusNextCell(e.shiftKey ? 'prev' : 'next'); + } + }} + onBlur={() => { + if (committingRef.current) { + committingRef.current = false; + onCellBlur?.(); + return; + } + onCellBlur?.(); + if (commitEditOn.includes('blur')) onEditCommit(); + else onEditCancel(); + }} + style={{ + background: 'none', + border: 'none', + outline: 'none', + padding: 0, + width: '100%', + font: 'inherit', + color: 'inherit', + }} + /> + ) + ) : render ? ( + render(record, index) + ) : defaultRender ? ( + defaultRender(record, index, accessor) + ) : ( + (getValueAtPath(record, accessor) as React.ReactNode) + )} ); } diff --git a/package/types/DataTableColumn.ts b/package/types/DataTableColumn.ts index ef65a2a4..b7ac0399 100644 --- a/package/types/DataTableColumn.ts +++ b/package/types/DataTableColumn.ts @@ -1,5 +1,6 @@ import type { MantineStyleProp, MantineTheme, PopoverProps } from '@mantine/core'; import type { DataTableColumnTextAlign } from './DataTableColumnTextAlign'; +import type { DataTableEditRenderContext } from './DataTableEditRenderContext'; export type DataTableColumn> = { /** @@ -179,6 +180,27 @@ export type DataTableColumn> = { * Optional style passed to the column footer. */ footerStyle?: MantineStyleProp; + + /** + * If true, cells in this column are editable on double-click. + * Can be a function receiving the current record and its index, returning a boolean, + * allowing per-cell control over editability. + */ + editable?: boolean | ((record: T, index: number) => boolean); + + /** + * Custom edit UI for this column. + * If provided, replaces the built-in unstyled TextInput when a cell enters edit mode. + * Receives the current record, its index, and a context with value/onChange/onCommit/onCancel. + */ + editRender?: (record: T, index: number, context: DataTableEditRenderContext) => React.ReactNode; + + /** + * Events that commit the edit for cells in this column. + * Overrides `defaultCommitEditOn` set at the DataTable level. + * @default ['blur', 'enter'] + */ + commitEditOn?: ('blur' | 'enter')[]; } & ( | { /** diff --git a/package/types/DataTableEditRenderContext.ts b/package/types/DataTableEditRenderContext.ts new file mode 100644 index 00000000..35be5662 --- /dev/null +++ b/package/types/DataTableEditRenderContext.ts @@ -0,0 +1,8 @@ +export type DataTableEditRenderContext = { + value: string; + onChange: (value: string) => void; + /** Commit the edit. Pass an optional value to override the tracked input value + * (useful for custom controls like Select that change + commit in one event). */ + onCommit: (value?: string) => void; + onCancel: () => void; +}; diff --git a/package/types/DataTableProps.ts b/package/types/DataTableProps.ts index b9dfab47..f52f2e97 100644 --- a/package/types/DataTableProps.ts +++ b/package/types/DataTableProps.ts @@ -163,6 +163,42 @@ export type DataTableProps> = { */ onCellContextMenu?: DataTableCellClickHandler; + /** + * Callback fired when an inline cell edit is committed. + * Receives the updated record, its index, the column accessor, and the new string value. + */ + onCellEdit?: (params: { + record: T; + index: number; + accessor: keyof T | (string & NonNullable); + value: string; + }) => void; + + /** + * Default commit trigger events for all editable columns. + * Can be overridden per column via `commitEditOn`. + * @default ['blur', 'enter'] + */ + defaultCommitEditOn?: ('blur' | 'enter')[]; + + /** + * Edit mode for editable cells. + * - `'cell'` (default): double-click a cell to enter edit mode one at a time. + * - `'global'`: all editable cells are in edit mode simultaneously. + * @default 'cell' + */ + editMode?: 'cell' | 'global'; + + /** + * Style applied to the `` of the currently focused editing cell. + */ + editingCellStyle?: MantineStyleProp; + + /** + * Class name applied to the `` of the currently focused editing cell. + */ + editingCellClassName?: string; + /** * Function to call when a row is clicked. * Receives an object with the current record, its index in `records` and the click event diff --git a/package/types/index.ts b/package/types/index.ts index 3a4154c5..1236312f 100644 --- a/package/types/index.ts +++ b/package/types/index.ts @@ -4,6 +4,7 @@ export * from './DataTableColumnGroup'; export * from './DataTableColumnTextAlign'; export * from './DataTableDefaultColumnProps'; export * from './DataTableDraggableRowProps'; +export * from './DataTableEditRenderContext'; export * from './DataTableEmptyStateProps'; export * from './DataTableOuterBorderProps'; export * from './DataTablePaginationProps'; From 577c63582b8eaf55355856593e81136f340466eb Mon Sep 17 00:00:00 2001 From: lukasbash Date: Wed, 24 Jun 2026 19:17:29 +0200 Subject: [PATCH 2/2] docs: add editing cells example page Four examples on /examples/editing-cells: - Basic editable columns with onCellEdit updating local state - Per-cell editability using the editable predicate function - onCellEdit callback with a visible commit log showing the payload shape - Custom editRender with a Mantine Select (single-event commit via onCommit) - Global edit mode (editMode="global") with Tab/Shift-Tab cell navigation and focused-cell highlight via editingCellStyle --- app/config.ts | 5 + .../EditingCellsCallbackExample.tsx | 50 +++++++++ .../EditingCellsCustomRenderExample.tsx | 104 ++++++++++++++++++ .../editing-cells/EditingCellsExample.tsx | 32 ++++++ .../EditingCellsGlobalModeExample.tsx | 46 ++++++++ app/examples/editing-cells/page.tsx | 76 +++++++++++++ 6 files changed, 313 insertions(+) create mode 100644 app/examples/editing-cells/EditingCellsCallbackExample.tsx create mode 100644 app/examples/editing-cells/EditingCellsCustomRenderExample.tsx create mode 100644 app/examples/editing-cells/EditingCellsExample.tsx create mode 100644 app/examples/editing-cells/EditingCellsGlobalModeExample.tsx create mode 100644 app/examples/editing-cells/page.tsx diff --git a/app/config.ts b/app/config.ts index 136787a3..1e866f98 100644 --- a/app/config.ts +++ b/app/config.ts @@ -182,6 +182,11 @@ export const ROUTES: RouteInfo[] = [ title: 'Handling cell clicks', description: `Example: handling cell click events on ${PRODUCT_NAME}`, }, + { + href: '/examples/editing-cells', + title: 'Editing cells', + description: `Example: how to implement inline cell editing with ${PRODUCT_NAME}`, + }, { href: '/examples/using-with-mantine-contextmenu', title: `Using with ${MANTINE_CONTEXTMENU_PRODUCT_NAME}`, diff --git a/app/examples/editing-cells/EditingCellsCallbackExample.tsx b/app/examples/editing-cells/EditingCellsCallbackExample.tsx new file mode 100644 index 00000000..4b11af7e --- /dev/null +++ b/app/examples/editing-cells/EditingCellsCallbackExample.tsx @@ -0,0 +1,50 @@ +'use client'; + +import { DataTable } from '__PACKAGE__'; +import { Code, Stack, Text } from '@mantine/core'; +import { useState } from 'react'; +import { type Company, companies } from '~/data'; + +const initialRecords = companies.slice(0, 3); + +type EditEvent = { accessor: string; value: string; recordId: number }; + +export function EditingCellsCallbackExample() { + const [records, setRecords] = useState(initialRecords); + const [log, setLog] = useState([]); + + return ( + // example-start + + { + setRecords((current) => + current.map((r) => (r.id === record.id ? { ...r, [accessor as keyof Company]: value } : r)) + ); + setLog((current) => [{ accessor: String(accessor), value, recordId: record.id }, ...current].slice(0, 5)); + }} + /> + {log.length > 0 && ( + + + Last commits (newest first): + + {log.map((entry, i) => ( + + {`{ accessor: "${entry.accessor}", value: "${entry.value}", record.id: ${entry.recordId} }`} + + ))} + + )} + + // example-end + ); +} diff --git a/app/examples/editing-cells/EditingCellsCustomRenderExample.tsx b/app/examples/editing-cells/EditingCellsCustomRenderExample.tsx new file mode 100644 index 00000000..f16fa8f6 --- /dev/null +++ b/app/examples/editing-cells/EditingCellsCustomRenderExample.tsx @@ -0,0 +1,104 @@ +'use client'; + +import { DataTable } from '__PACKAGE__'; +import { Select } from '@mantine/core'; +import { useState } from 'react'; +import { type Company, companies } from '~/data'; + +const initialRecords = companies.slice(0, 5); + +const US_STATES = [ + 'AK', + 'AL', + 'AR', + 'AZ', + 'CA', + 'CO', + 'CT', + 'DC', + 'DE', + 'FL', + 'GA', + 'HI', + 'IA', + 'ID', + 'IL', + 'IN', + 'KS', + 'KY', + 'LA', + 'MA', + 'MD', + 'ME', + 'MI', + 'MN', + 'MO', + 'MS', + 'MT', + 'NC', + 'ND', + 'NE', + 'NH', + 'NJ', + 'NM', + 'NV', + 'NY', + 'OH', + 'OK', + 'OR', + 'PA', + 'RI', + 'SC', + 'SD', + 'TN', + 'TX', + 'UT', + 'VA', + 'VT', + 'WA', + 'WI', + 'WV', + 'WY', +]; + +export function EditingCellsCustomRenderExample() { + const [records, setRecords] = useState(initialRecords); + + return ( + // example-start + ( +