import React from 'react' import { useGridComponents } from '../context/grid-components-context/hook' import { size, useIsFocusVisible } from '@planview/pv-utilities' import { useGridSelector, useGridContext } from '../context/grid-context/hook' import { useHoverSync } from '../context/grid-sync-context' import type { VirtualItem } from '@tanstack/react-virtual' import { ROW_FOCUS_ID, SELECTION_COLUMN_ID } from '../constants' import type { GridRowId } from '../types' import type { StickyColumnDetails } from '../state' import { useGridActionsMenu } from '../context/grid-actions-menu-context' import { useDraggable } from '@dnd-kit/core' import { DRAG_TYPE_ROW } from './grid-drag-controller' import { isMac } from '../utils/is-mac' import { getColumnSpanByRowId, healVirtualColumnsForRow, } from '../utils/column-span' import { createSelector } from 'reselect' export type GridRowProps = { id: GridRowId virtualStart: number virtualHeight: number virtualColumns: VirtualItem[] stickyColumns: StickyColumnDetails width: number } const getDecoupledIndex = (virtualColumns: VirtualItem[]): number | null => { if (virtualColumns.length < 2) { return null } const lastDecoupled = virtualColumns[virtualColumns.length - 1].start - virtualColumns[virtualColumns.length - 2].end > 0 const firstDecoupled = virtualColumns[1].start - virtualColumns[0].end > 0 return lastDecoupled ? virtualColumns.length - 1 : firstDecoupled ? 0 : null } //This empty label is added to prevent assistive technology from reading out the contents of the tr const emptyLabel = ' ' export const GridRow = ({ id, virtualHeight, virtualStart, virtualColumns: virtualColumnsIncoming, stickyColumns, width, }: GridRowProps) => { const { GridCell, GridCellLoading, GridRowLayout } = useGridComponents() const grid = useGridContext() const { visible: actionsMenuVisible } = useGridActionsMenu() const { selectors } = useGridContext() const selectionMode = useGridSelector(selectors.selectSelectionMode) const spreadsheetMode = useGridSelector(selectors.selectIsSpreadsheet) const canSelect = selectionMode !== 'none' && !spreadsheetMode const isVisuallySelected = useGridSelector((state) => selectors.selectIsRowVisuallySelected(state, id) ) const isSelected = useGridSelector( (state) => selectors.selectRowSelectionState(state, id) === 'all' ) const columnIds = useGridSelector(selectors.selectColumnIds) const columnWidths = useGridSelector(selectors.selectColumnWidths) const colSpanConfigSelector = React.useMemo( () => /* A custom selector is created due to the frequency this will be called. With reselect < 5, we cannot cache per-row items at the compositeSelectors layer, so we need to create a custom selector for each row. */ createSelector( [ (state) => selectors.selectRow(state, id), (state) => selectors.selectRowMeta(state, id), selectors.selectStickyColumnIds, selectors.selectColSpanConfig, ], ( row, rowMeta, stickyColumnIds, { colSpanFunctions, columnIds } ) => { if (!row || !colSpanFunctions.length) { return null } return getColumnSpanByRowId({ row, rowMeta, stickyColumnIds, colSpanFunctions, columnIds, }) } ), [id, selectors] ) const colSpanConfig = useGridSelector(colSpanConfigSelector) const headerRowCount = useGridSelector((state) => selectors.selectHeaderRowCount(state) ) const rowIndex = useGridSelector((state) => selectors.selectRowIndex(state, id) ) const isLoaded = useGridSelector((state) => selectors.selectIsRowLoaded(state, id) ) const excluded = useGridSelector((state) => isLoaded ? selectors.selectRowExcluded(state, id) : false ) const isExpandable = useGridSelector((state) => selectors.selectIsRowExpandable(state, id) ) const isExpanded = useGridSelector((state) => isExpandable ? selectors.selectIsRowExpanded(state, id) : false ) const rowLevel = useGridSelector( (state) => grid.selectors.selectRowLevel(state, id) + 1 ) const ariaSetSize = useGridSelector((state) => grid.selectors.selectAriaSetSize(state, id) ) const ariaPosInset = useGridSelector((state) => grid.selectors.selectAriaPosInset(state, id) ) const hasFooter = useGridSelector((state) => selectors.selectAggregationEnabled(state) ) const hasFocus = useGridSelector((state) => grid.selectors.selectHasFocus(state, ROW_FOCUS_ID, 'body', id) ) const { innerRef: _innerRef, focusVisible, ...focusProps } = useIsFocusVisible() const { isHovered, onMouseEnter, onMouseLeave } = useHoverSync(id) const virtualColumns = React.useMemo(() => { if (!colSpanConfig) { return virtualColumnsIncoming } return healVirtualColumnsForRow({ virtualColumns: virtualColumnsIncoming, colSpanConfig, columnIds, columnWidths, }) }, [columnIds, columnWidths, colSpanConfig, virtualColumnsIncoming]) const onClick = React.useCallback( (e: React.MouseEvent) => { if (isMac && e.ctrlKey && e.button === 0) { /* This is a Mac right click */ return } if (selectors.selectIsEditing(grid.getState())) { return } if (canSelect) { const modifierPressed = e.metaKey || e.ctrlKey grid.api.selection.select( id, !(isSelected && modifierPressed), !modifierPressed, e.shiftKey ) if (e.shiftKey) { document.getSelection()?.removeAllRanges() } } grid.events.emit('onRowClick', id) }, [selectors, grid, canSelect, id, isSelected] ) const onContextMenu = React.useCallback( (e: React.MouseEvent) => { const target = e.target as HTMLElement /* Don't intercept links or form elements. Ensure event originated as a direct descendant and not via a react portal. */ if ( target.closest('a, input, textarea, select') || !e.currentTarget.contains(target) ) { return } const state = grid.getState() const enabled = grid.selectors.selectActionsMenuEnabled(state) if (!enabled) { return } const row = grid.selectors.selectRow(state, id) const rowMeta = grid.selectors.selectRowMeta(state, id) if (actionsMenuVisible({ row, rowMeta })) { // We don't want to prevent default if the menu is not enabled or visible e.preventDefault() grid.api.actionsMenu.show(id, { top: e.clientY, left: e.clientX, }) } }, [id, grid, actionsMenuVisible] ) function renderCell(columnId: string, virtualStart?: number) { const CellComponent = isLoaded || columnId === SELECTION_COLUMN_ID ? GridCell : GridCellLoading const config = colSpanConfig?.get(columnId) if (!columnId || config?.skip) { /* There is a risk that an extra column is rendered for focus, but it has been removed before the range extractor is aware of it. */ return null } return ( ) } const isDragging = useGridSelector((state) => grid.selectors.selectIsRowDragging(state, id) ) const isDraggingEnabled = useGridSelector( grid.selectors.selectIsDraggingEnabled ) const canDragRow = useGridSelector((state) => isDraggingEnabled ? grid.selectors.selectCanDragRow(state, id) : false ) const dragDisabled = !canDragRow || !isLoaded const { attributes, listeners, setNodeRef } = useDraggable({ id, data: { type: DRAG_TYPE_ROW, virtualStart, }, disabled: dragDisabled, }) const onPointerDown = React.useCallback( (e: React.PointerEvent) => { if (e.target === e.currentTarget && !dragDisabled) { grid.api.focus.set(id, ROW_FOCUS_ID, 'body', 'last') } }, [grid.api, id, dragDisabled] ) const adjustedListeners = React.useMemo(() => { if (spreadsheetMode) { const validateTarget = ( target: EventTarget, currentTarget: EventTarget ) => target === currentTarget || (target instanceof Element && target.closest('[data-grid-row-drag-handle]')) return { onKeyDown: (e: React.KeyboardEvent) => { if (validateTarget(e.target, e.currentTarget)) { listeners?.onKeyDown?.(e) } }, onTouchStart: (e: React.TouchEvent) => { if (validateTarget(e.target, e.currentTarget)) { listeners?.onTouchStart?.(e) } }, onMouseDown: (e: React.MouseEvent) => { if (validateTarget(e.target, e.currentTarget)) { listeners?.onMouseDown?.(e) } }, } } return listeners }, [spreadsheetMode, listeners]) /** * Figure out which column is decoupled from virtualized items: Meaning if is kept in the DOM because it has tab-index="0" * This is done to position the decoupled item correctly (as if all items were rendered) * so that the list can scroll the item into view if the item get's focus again. */ const decoupledIndex = getDecoupledIndex(virtualColumns) const firstColumn = decoupledIndex === 0 ? virtualColumns[1] : virtualColumns[0] return ( {stickyColumns.left.columns.map(({ id }) => renderCell(id))} {virtualColumns.map((virtualItem, ix) => { const { index } = virtualItem const columnId = columnIds[index] return renderCell( columnId, ix === decoupledIndex ? virtualItem.start : undefined ) })} {stickyColumns.right.columns.map(({ id }) => renderCell(id))} ) } export const GridRowMemo = React.memo(GridRow) as typeof GridRow