import React from 'react' import { useGridComponents } from '../context/grid-components-context/hook' import { useGridContext, useGridSelector } from '../context/grid-context/hook' import { useCellStickySizing, useColumnIndex, useFocusHandler } from '../hooks' import type { GridRowId, EditorConfirmPayload, GridConfirmPayload, GridRowMeta, } from '../types' import { useIsFocusVisible } from '@planview/pv-utilities' import { SubNavigationContext, useSubFocusHandler, } from '../context/grid-sub-navigation-context' import type { GridEditorContextType } from '../context/grid-editor-context' import { GridEditorContext } from '../context/grid-editor-context' import { GridCellFocusHeal } from './grid-cell-focus-heal' import { useColumnBorders } from '../hooks/use-column-borders' import { ACTIONS_COLUMN_ID, SELECTION_COLUMN_ID } from '../constants' export type GridCellProps = { rowId: GridRowId columnId: string renderColumnId?: string spanColumns?: string[] virtualStart?: number } const NO_SPAN: never[] = [] export const GridCell = ({ rowId, columnId, renderColumnId, virtualStart, spanColumns = NO_SPAN, }: GridCellProps) => { const { GridCellLayout, GridCellTree, GridCellFocusLayout } = useGridComponents() const grid = useGridContext() const { selectColumn, selectRow } = grid.selectors const actingColumnId = renderColumnId ?? columnId const { actualWidth: baseWidth, data: column } = useGridSelector((state) => selectColumn(state, actingColumnId) ) const extendedWidth = useGridSelector((state) => { if (spanColumns.length === 0) { return 0 } return spanColumns.reduce((acc, colId) => { const col = selectColumn(state, colId) return acc + col.actualWidth }, 0) }) const actualWidth = extendedWidth || baseWidth const columnIndex = useColumnIndex(columnId) const data = useGridSelector((state) => selectRow(state, rowId)) const [hasFocus, handleGridFocus] = useFocusHandler(columnId, 'body', rowId) const spreadsheetMode = useGridSelector((state) => grid.selectors.selectIsSpreadsheet(state) ) const isInRange = useGridSelector((state) => grid.selectors.selectIsInRangeSelection(state, columnId, rowId) ) const rangeBorders = useGridSelector((state) => grid.selectors.selectCellRangeBorders(state, columnId, rowId) ) const isTreeColumn = !!column.tree const meta: GridRowMeta = useGridSelector((state) => grid.selectors.selectRowMeta(state, rowId) ) const level = useGridSelector((state) => isTreeColumn ? grid.selectors.selectRowLevel(state, rowId) : 0 ) const isExpanded = useGridSelector((state) => isTreeColumn ? grid.selectors.selectIsRowExpanded(state, rowId) : false ) const treeIndentSize = useGridSelector(grid.selectors.selectTreeIndentSize) const { showLeftBorder, showRightBorder } = useColumnBorders( spanColumns && spanColumns.length >= 2 ? [spanColumns[0], spanColumns[spanColumns.length - 1]] : columnId, 'body' ) const isExpandable = meta.type === 'group' || meta.type === 'tree' const { onKeyDown, value: subFocusValue, rendererWantsToDisplayFocusStyles, registeredIndexes, subFocus, } = useSubFocusHandler( columnId, 'body', rowId, isTreeColumn && isExpandable ? 1 : 0 ) const CellComponent = column.cell?.Renderer const EditorComponent = column.cell?.Editor const elRef = React.useRef(null) const hasFocusRef = React.useRef(hasFocus) const { onFocus, ...focusProps } = useIsFocusVisible() const editable = typeof column.cell?.editable === 'function' ? column.cell.editable({ columnId: actingColumnId, row: data, rowMeta: meta, }) : !!column.cell?.editable const inEditMode = useGridSelector((state) => grid.selectors.selectIsEditingCell(state, { rowId, columnId: actingColumnId, }) ) const hybridEditor = column.cell?.hybridEditor const isEditing = (editable && (hybridEditor || inEditMode)) || hybridEditor === 'always' const gridSupportsEdit = useGridSelector( grid.selectors.selectGridSupportsEdit ) const readOnly = gridSupportsEdit && !editable ? true : undefined const onClickHandler = React.useCallback( (e) => { if (editable && !hybridEditor && !isEditing) { e.stopPropagation() grid.api.edit.start({ columnId: actingColumnId, rowId }) } }, [ editable, hybridEditor, isEditing, grid.api.edit, actingColumnId, rowId, ] ) React.useEffect(() => { hasFocusRef.current = hasFocus }, [hasFocus]) React.useLayoutEffect( () => () => { if ( hasFocusRef.current && elRef.current?.contains(document.activeElement) ) { grid.events.emit('onFocusUnmount') } }, [grid] ) const { sticky, style } = useCellStickySizing({ columnId, column, virtualStart, actualWidth, }) const value = column.cell?.value ? column.cell.value({ columnId: actingColumnId, row: data, rowMeta: meta, }) : data?.[actingColumnId] const label = column.cell?.label ? column.cell?.label({ columnId: actingColumnId, row: data, rowMeta: meta, value, }) : value == null ? '' : value.toString() const handleKeyDown = React.useCallback( (e) => { if (e.key === 'Enter') { e.preventDefault() e.stopPropagation() grid.api.edit.start({ columnId: actingColumnId, rowId }) } }, [grid, actingColumnId, rowId] ) const handleConfirm = React.useCallback( (val: unknown, settings: EditorConfirmPayload = {}) => { const confirm: GridConfirmPayload = { rowId, columnId: actingColumnId, previousValue: value, nextValue: val, } if (!settings.continueEditing) { grid.api.edit.stop() } /* Don't alert change events if value strict equals the same */ if (val !== value) { grid.events.emit('onCellChange', confirm) } if (settings.continueEditing === 'previous') { grid.api.edit.previous() } else if (settings.continueEditing === 'next') { grid.api.edit.next() } }, [grid, actingColumnId, rowId, value] ) const handleCancel = React.useCallback(() => { grid.api.edit.stop() }, [grid]) const handleFocus = React.useCallback>( (e) => { handleGridFocus() onFocus(e) if (e.target === elRef.current) { const target = elRef.current.querySelector("[tabIndex='0']") if (target) { target.focus() } } }, [handleGridFocus, onFocus] ) const layoutKeyboardHandler: React.KeyboardEventHandler = React.useCallback( (e) => { onKeyDown(e) if (editable && !isEditing) { handleKeyDown(e) } }, [editable, handleKeyDown, isEditing, onKeyDown] ) const functionalTabIndex = isTreeColumn && (meta.type === 'group' || meta.type === 'tree') && hasFocus && !isEditing && (subFocus === 'first' || subFocus === 0) ? 0 : -1 /** This means the renderer OR child of renderer has focus */ const rendererHasFocus = hasFocus && functionalTabIndex === -1 /** This is the tabIndex passed to the renderer, we dot use it if sub-navigation is enabled */ const rendererTabIndex = rendererHasFocus && !registeredIndexes ? 0 : -1 const editorContextValue = React.useMemo( () => ({ editable }), [editable] ) const spreadsheetModeFocus = spreadsheetMode && columnId !== SELECTION_COLUMN_ID && columnId !== ACTIONS_COLUMN_ID const showRangeSelection = spreadsheetMode && isInRange && !inEditMode const content = ( { if (isTreeColumn && isExpandable) { if (subFocus === 0 || subFocus === 'first') { subFocusValue.setCurrentSubFocusIndex(1) } } }} $leftBorder={showLeftBorder} $rightBorder={showRightBorder} > {isEditing && EditorComponent ? ( ) : CellComponent ? ( ) : ( label )} ) return ( 1 ? spanColumns.length : undefined} aria-colindex={columnIndex + 1} role="gridcell" aria-selected={spreadsheetMode ? isInRange : undefined} style={style} $sticky={!!sticky} onClick={ isTreeColumn || spreadsheetMode ? undefined : onClickHandler } onDoubleClick={ isTreeColumn || !spreadsheetMode ? undefined : onClickHandler } onPointerDown={handleGridFocus} onKeyDown={layoutKeyboardHandler} onFocus={handleFocus} onBlur={focusProps.onBlur} aria-readonly={readOnly} tabIndex={-1} data-current-cell={hasFocus ? 'true' : undefined} $inRange={showRangeSelection} $inRangeBorders={showRangeSelection ? rangeBorders : 0} $editable={editable} > {isTreeColumn ? ( { if (subFocus && subFocus !== 'first') { subFocusValue.setCurrentSubFocusIndex(0) } grid.api.tree.expand( rowId, !isExpanded, e.metaKey || e.ctrlKey ) }} > {content} ) : ( content )} ) } export const GridCellMemo = React.memo(GridCell) as typeof GridCell