import { CellContext } from "@tanstack/react-table" import React, { useCallback, useEffect, useMemo, useRef, useState } from "react" import { useDataGridContext } from "../context" import { DataGridCellContext, DataGridCellRenderProps, DataGridCoordinates, } from "../types" import { isCellMatch, isSpecialFocusKey } from "../utils" type UseDataGridCellOptions = { context: CellContext } const textCharacterRegex = /^.$/u const numberCharacterRegex = /^[0-9]$/u export const useDataGridCell = ({ context, }: UseDataGridCellOptions) => { const { register, control, anchor, setIsEditing, setSingleRange, setIsSelecting, setRangeEnd, getWrapperFocusHandler, getWrapperMouseOverHandler, getInputChangeHandler, getIsCellSelected, getIsCellDragSelected, getCellMetadata, } = useDataGridContext() const { rowIndex, columnIndex } = context as DataGridCellContext< TData, TValue > const coords: DataGridCoordinates = useMemo( () => ({ row: rowIndex, col: columnIndex }), [rowIndex, columnIndex] ) const { id, field, type, innerAttributes, inputAttributes } = useMemo(() => { return getCellMetadata(coords) }, [coords, getCellMetadata]) const [showOverlay, setShowOverlay] = useState(true) const containerRef = useRef(null) const inputRef = useRef(null) const handleOverlayMouseDown = useCallback( (e: React.MouseEvent) => { e.preventDefault() e.stopPropagation() if (e.detail === 2) { if (inputRef.current) { setShowOverlay(false) inputRef.current.focus() return } } if (e.shiftKey) { // Only allow setting the rangeEnd if the column matches the anchor column. // If not we let the function continue and treat the click as if the shift key was not pressed. if (coords.col === anchor?.col) { setRangeEnd(coords) return } } if (containerRef.current) { setSingleRange(coords) setIsSelecting(true) containerRef.current.focus() } }, [coords, anchor, setRangeEnd, setSingleRange, setIsSelecting] ) const handleBooleanInnerMouseDown = useCallback( (e: React.MouseEvent) => { e.preventDefault() e.stopPropagation() if (e.detail === 2) { inputRef.current?.focus() return } if (e.shiftKey) { setRangeEnd(coords) return } if (containerRef.current) { setSingleRange(coords) setIsSelecting(true) containerRef.current.focus() } }, [setIsSelecting, setSingleRange, setRangeEnd, coords] ) const handleInputBlur = useCallback(() => { setShowOverlay(true) setIsEditing(false) }, [setIsEditing]) const handleInputFocus = useCallback(() => { setShowOverlay(false) setIsEditing(true) }, [setIsEditing]) const validateKeyStroke = useCallback( (key: string) => { switch (type) { case "togglable-number": case "number": return numberCharacterRegex.test(key) case "text": case "multiline-text": return textCharacterRegex.test(key) default: // KeyboardEvents should not be forwareded to other types of cells return false } }, [type] ) const handleContainerKeyDown = useCallback( (e: React.KeyboardEvent) => { if (!inputRef.current || !validateKeyStroke(e.key) || !showOverlay) { return } // Allow the user to undo/redo if (e.key.toLowerCase() === "z" && (e.ctrlKey || e.metaKey)) { return } // Allow the user to copy if (e.key.toLowerCase() === "c" && (e.ctrlKey || e.metaKey)) { return } // Allow the user to paste if (e.key.toLowerCase() === "v" && (e.ctrlKey || e.metaKey)) { return } if (e.key === "Enter") { return } if (isSpecialFocusKey(e.nativeEvent)) { return } inputRef.current.focus() setShowOverlay(false) if (inputRef.current instanceof HTMLInputElement) { // Clear the current value inputRef.current.value = "" // Simulate typing the new key const nativeInputValueSetter = Object.getOwnPropertyDescriptor( window.HTMLInputElement.prototype, "value" )?.set nativeInputValueSetter?.call(inputRef.current, e.key) const event = new Event("input", { bubbles: true }) inputRef.current.dispatchEvent(event) } else if (inputRef.current instanceof HTMLTextAreaElement) { inputRef.current.value = "" const nativeTextAreaValueSetter = Object.getOwnPropertyDescriptor( window.HTMLTextAreaElement.prototype, "value" )?.set nativeTextAreaValueSetter?.call(inputRef.current, e.key) // Trigger input event to notify react-hook-form const event = new Event("input", { bubbles: true }) inputRef.current.dispatchEvent(event) } // Prevent the original event from propagating e.stopPropagation() e.preventDefault() }, [showOverlay, validateKeyStroke] ) const isAnchor = useMemo(() => { return anchor ? isCellMatch(coords, anchor) : false }, [anchor, coords]) const fieldWithoutOverlay = useMemo(() => { return type === "boolean" }, [type]) useEffect(() => { if (isAnchor && !containerRef.current?.contains(document.activeElement)) { containerRef.current?.focus({ preventScroll: true }) } }, [isAnchor]) const renderProps: DataGridCellRenderProps = { container: { field, isAnchor, isSelected: getIsCellSelected(coords), isDragSelected: getIsCellDragSelected(coords), showOverlay: fieldWithoutOverlay ? false : showOverlay, innerProps: { ref: containerRef, onMouseOver: getWrapperMouseOverHandler(coords), onMouseDown: type === "boolean" ? handleBooleanInnerMouseDown : undefined, onKeyDown: handleContainerKeyDown, onFocus: getWrapperFocusHandler(coords), ...innerAttributes, }, overlayProps: { onMouseDown: handleOverlayMouseDown, }, }, input: { ref: inputRef, onBlur: handleInputBlur, onFocus: handleInputFocus, onChange: getInputChangeHandler(field), ...inputAttributes, }, } return { id, field, register, control, renderProps, } }