import React, { useCallback } from "react" import type { FieldValues, Path, PathValue, UseFormGetValues, UseFormSetValue, } from "react-hook-form" import { DataGridBulkUpdateCommand, DataGridMatrix, DataGridQueryTool, DataGridUpdateCommand, } from "../models" import { DataGridCoordinates } from "../types" type UseDataGridKeydownEventOptions = { containerRef: React.RefObject matrix: DataGridMatrix anchor: DataGridCoordinates | null rangeEnd: DataGridCoordinates | null isEditing: boolean scrollToCoordinates: ( coords: DataGridCoordinates, direction: "horizontal" | "vertical" | "both" ) => void setTrapActive: (active: boolean) => void setSingleRange: (coordinates: DataGridCoordinates | null) => void setRangeEnd: (coordinates: DataGridCoordinates | null) => void onEditingChangeHandler: (value: boolean) => void getValues: UseFormGetValues setValue: UseFormSetValue execute: (command: DataGridUpdateCommand | DataGridBulkUpdateCommand) => void undo: () => void redo: () => void queryTool: DataGridQueryTool | null getSelectionValues: ( fields: string[] ) => PathValue>[] setSelectionValues: (fields: string[], values: string[]) => void restoreSnapshot: () => void createSnapshot: (coords: DataGridCoordinates) => void } const ARROW_KEYS = ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"] const VERTICAL_KEYS = ["ArrowUp", "ArrowDown"] export const useDataGridKeydownEvent = < TData, TFieldValues extends FieldValues >({ containerRef, matrix, anchor, rangeEnd, isEditing, setTrapActive, scrollToCoordinates, setSingleRange, setRangeEnd, onEditingChangeHandler, getValues, setValue, execute, undo, redo, queryTool, getSelectionValues, setSelectionValues, restoreSnapshot, createSnapshot, }: UseDataGridKeydownEventOptions) => { const handleKeyboardNavigation = useCallback( (e: KeyboardEvent) => { if (!anchor) { return } const type = matrix.getCellType(anchor) /** * If the user is currently editing a cell, we don't want to * handle the keyboard navigation. * * If the cell is of type boolean, we don't want to ignore the * keyboard navigation, as we want to allow the user to navigate * away from the cell directly, as you cannot "enter" a boolean cell. */ if (isEditing && type !== "boolean") { return } const direction = VERTICAL_KEYS.includes(e.key) ? "vertical" : "horizontal" /** * If the user performs a horizontal navigation, we want to * use the anchor as the basis for the navigation. * * If the user performs a vertical navigation, the bases depends * on the type of interaction. If the user is holding shift, we want * to use the rangeEnd as the basis. If the user is not holding shift, * we want to use the anchor as the basis. */ const basis = direction === "horizontal" ? anchor : e.shiftKey ? rangeEnd : anchor const updater = direction === "horizontal" ? setSingleRange : e.shiftKey ? setRangeEnd : setSingleRange if (!basis) { return } const { row, col } = basis const handleNavigation = (coords: DataGridCoordinates) => { e.preventDefault() e.stopPropagation() scrollToCoordinates(coords, direction) updater(coords) } const next = matrix.getValidMovement( row, col, e.key, e.metaKey || e.ctrlKey ) handleNavigation(next) }, [ isEditing, anchor, rangeEnd, scrollToCoordinates, setSingleRange, setRangeEnd, matrix, ] ) const handleTabKey = useCallback( (e: KeyboardEvent) => { if (!anchor) { return } e.preventDefault() e.stopPropagation() const { row, col } = anchor const key = e.shiftKey ? "ArrowLeft" : "ArrowRight" const direction = "horizontal" const next = matrix.getValidMovement( row, col, key, e.metaKey || e.ctrlKey ) scrollToCoordinates(next, direction) setSingleRange(next) }, [anchor, scrollToCoordinates, setSingleRange, matrix] ) const handleUndo = useCallback( (e: KeyboardEvent) => { e.preventDefault() if (e.shiftKey) { redo() return } undo() }, [redo, undo] ) const handleSpaceKeyBoolean = useCallback( (anchor: DataGridCoordinates) => { const end = rangeEnd ?? anchor const fields = matrix.getFieldsInSelection(anchor, end) const prev = getSelectionValues(fields) as boolean[] const allChecked = prev.every((value) => value === true) const next = Array.from({ length: prev.length }, () => !allChecked) const command = new DataGridBulkUpdateCommand({ fields, next, prev, setter: setSelectionValues, }) execute(command) }, [rangeEnd, matrix, getSelectionValues, setSelectionValues, execute] ) const handleSpaceKeyText = useCallback( (anchor: DataGridCoordinates) => { const field = matrix.getCellField(anchor) const input = queryTool?.getInput(anchor) if (!field || !input) { return } createSnapshot(anchor) input.focus() }, [matrix, queryTool, createSnapshot] ) const handleSpaceKeyNumber = useCallback( (anchor: DataGridCoordinates) => { const field = matrix.getCellField(anchor) const input = queryTool?.getInput(anchor) if (!field || !input) { return } createSnapshot(anchor) const current = getValues(field as Path) const next = "" const command = new DataGridUpdateCommand({ next, prev: current, setter: (value) => { setValue(field as Path, value, { shouldDirty: true, shouldTouch: true, }) }, }) execute(command) input.focus() }, [matrix, queryTool, getValues, execute, setValue, createSnapshot] ) const handleSpaceKeyTogglableNumber = useCallback( (anchor: DataGridCoordinates) => { const field = matrix.getCellField(anchor) const input = queryTool?.getInput(anchor) if (!field || !input) { return } createSnapshot(anchor) const current = getValues(field as Path) let checked = current.checked // If the toggle is not disabled, then we want to uncheck the toggle. if (!current.disabledToggle) { checked = false } const next = { ...current, quantity: "", checked } const command = new DataGridUpdateCommand({ next, prev: current, setter: (value) => { setValue(field as Path, value, { shouldDirty: true, shouldTouch: true, }) }, }) execute(command) input.focus() }, [matrix, queryTool, getValues, execute, setValue, createSnapshot] ) const handleSpaceKey = useCallback( (e: KeyboardEvent) => { if (!anchor || isEditing) { return } e.preventDefault() const type = matrix.getCellType(anchor) if (!type) { return } switch (type) { case "boolean": handleSpaceKeyBoolean(anchor) break case "togglable-number": handleSpaceKeyTogglableNumber(anchor) break case "text": case "multiline-text": handleSpaceKeyText(anchor) break case "number": handleSpaceKeyNumber(anchor) break } }, [ anchor, isEditing, matrix, handleSpaceKeyBoolean, handleSpaceKeyText, handleSpaceKeyNumber, handleSpaceKeyTogglableNumber, ] ) const handleMoveOnEnter = useCallback( (e: KeyboardEvent, anchor: DataGridCoordinates) => { const direction = e.shiftKey ? "ArrowUp" : "ArrowDown" const pos = matrix.getValidMovement( anchor.row, anchor.col, direction, false ) if (anchor.row !== pos.row || anchor.col !== pos.col) { setSingleRange(pos) scrollToCoordinates(pos, "vertical") } else { // If the user is at the last cell, we want to focus the container of the cell. const container = queryTool?.getContainer(anchor) container?.focus() } onEditingChangeHandler(false) }, [ queryTool, matrix, scrollToCoordinates, setSingleRange, onEditingChangeHandler, ] ) const handleEditOnEnter = useCallback( (anchor: DataGridCoordinates) => { const input = queryTool?.getInput(anchor) if (!input) { return } input.focus() onEditingChangeHandler(true) }, [queryTool, onEditingChangeHandler] ) /** * Handles the enter key for text and number cells. * * The behavior is as follows: * - If the cell is currently not being edited, start editing the cell. * - If the cell is currently being edited, move to the next cell. */ const handleEnterKeyTextOrNumber = useCallback( (e: KeyboardEvent, anchor: DataGridCoordinates) => { if (isEditing) { handleMoveOnEnter(e, anchor) return } handleEditOnEnter(anchor) }, [handleMoveOnEnter, handleEditOnEnter, isEditing] ) /** * Handles the enter key for multiline-text cells. * * The behavior is as follows: * - If Shift+Enter is pressed while editing, allow the newline (don't prevent default). * - If Enter is pressed while editing (without Shift), move to the next cell. * - If the cell is currently not being edited, start editing the cell. */ const handleEnterKeyMultilineText = useCallback( (e: KeyboardEvent, anchor: DataGridCoordinates) => { if (isEditing) { if (e.shiftKey) { return } handleMoveOnEnter(e, anchor) return } handleEditOnEnter(anchor) }, [handleMoveOnEnter, handleEditOnEnter, isEditing] ) /** * Handles the enter key for boolean cells. * * The behavior is as follows: * - If the cell is currently undefined, set it to true. * - If the cell is currently a boolean, invert the value. * - After the value has been set, move to the next cell. */ const handleEnterKeyBoolean = useCallback( (e: KeyboardEvent, anchor: DataGridCoordinates) => { const field = matrix.getCellField(anchor) if (!field) { return } const current = getValues(field as Path) let next: boolean if (typeof current === "boolean") { next = !current } else { next = true } const command = new DataGridUpdateCommand({ next, prev: current, setter: (value) => { setValue(field as Path, value, { shouldDirty: true, shouldTouch: true, }) }, }) execute(command) handleMoveOnEnter(e, anchor) }, [execute, getValues, handleMoveOnEnter, matrix, setValue] ) const handleEnterKey = useCallback( (e: KeyboardEvent) => { if (!anchor) { return } const type = matrix.getCellType(anchor) if (type === "multiline-text" && isEditing && e.shiftKey) { return } e.preventDefault() switch (type) { case "multiline-text": handleEnterKeyMultilineText(e, anchor) break case "togglable-number": case "text": case "number": handleEnterKeyTextOrNumber(e, anchor) break case "boolean": { handleEnterKeyBoolean(e, anchor) break } } }, [ anchor, matrix, isEditing, handleEnterKeyTextOrNumber, handleEnterKeyBoolean, handleEnterKeyMultilineText, ] ) const handleDeleteKeyTogglableNumber = useCallback( (anchor: DataGridCoordinates, rangeEnd: DataGridCoordinates) => { const fields = matrix.getFieldsInSelection(anchor, rangeEnd) const prev = getSelectionValues(fields) const next = prev.map((value) => ({ ...value, quantity: "", checked: value.disableToggle ? value.checked : false, })) const command = new DataGridBulkUpdateCommand({ fields, next, prev, setter: setSelectionValues, }) execute(command) }, [matrix, getSelectionValues, setSelectionValues, execute] ) const handleDeleteKeyTextOrNumber = useCallback( (anchor: DataGridCoordinates, rangeEnd: DataGridCoordinates) => { const fields = matrix.getFieldsInSelection(anchor, rangeEnd) const prev = getSelectionValues(fields) const next = Array.from({ length: prev.length }, () => "") const command = new DataGridBulkUpdateCommand({ fields, next, prev, setter: setSelectionValues, }) execute(command) }, [matrix, getSelectionValues, setSelectionValues, execute] ) const handleDeleteKeyBoolean = useCallback( (anchor: DataGridCoordinates, rangeEnd: DataGridCoordinates) => { const fields = matrix.getFieldsInSelection(anchor, rangeEnd) const prev = getSelectionValues(fields) const next = Array.from({ length: prev.length }, () => false) const command = new DataGridBulkUpdateCommand({ fields, next, prev, setter: setSelectionValues, }) execute(command) }, [execute, getSelectionValues, matrix, setSelectionValues] ) const handleDeleteKey = useCallback( (e: KeyboardEvent) => { if (!anchor || !rangeEnd || isEditing) { return } e.preventDefault() const type = matrix.getCellType(anchor) if (!type) { return } switch (type) { case "text": case "multiline-text": case "number": handleDeleteKeyTextOrNumber(anchor, rangeEnd) break case "boolean": handleDeleteKeyBoolean(anchor, rangeEnd) break case "togglable-number": handleDeleteKeyTogglableNumber(anchor, rangeEnd) break } }, [ anchor, rangeEnd, isEditing, matrix, handleDeleteKeyTextOrNumber, handleDeleteKeyBoolean, handleDeleteKeyTogglableNumber, ] ) const handleEscapeKey = useCallback( (e: KeyboardEvent) => { if (!anchor || !isEditing) { return } e.preventDefault() e.stopPropagation() // try to restore the previous value restoreSnapshot() // Restore focus to the container element const container = queryTool?.getContainer(anchor) container?.focus() }, [queryTool, isEditing, anchor, restoreSnapshot] ) const handleSpecialFocusKeys = useCallback( (e: KeyboardEvent) => { if (!containerRef || isEditing) { return } const focusableElements = getFocusableElements(containerRef) const focusElement = (element: HTMLElement | null) => { if (element) { setTrapActive(false) element.focus() } } switch (e.key) { case ".": focusElement(focusableElements.cancel) break case ",": focusElement(focusableElements.shortcuts) break default: break } }, [isEditing, setTrapActive, containerRef] ) const handleKeyDownEvent = useCallback( (e: KeyboardEvent) => { if (ARROW_KEYS.includes(e.key)) { handleKeyboardNavigation(e) return } if (e.key === "z" && (e.metaKey || e.ctrlKey)) { handleUndo(e) return } if (e.key === " ") { handleSpaceKey(e) return } if (e.key === "Delete" || e.key === "Backspace") { handleDeleteKey(e) return } if (e.key === "Enter") { handleEnterKey(e) return } if (e.key === "Escape") { handleEscapeKey(e) return } if (e.key === "Tab") { handleTabKey(e) return } }, [ handleEscapeKey, handleKeyboardNavigation, handleUndo, handleSpaceKey, handleEnterKey, handleDeleteKey, handleTabKey, ] ) return { handleKeyDownEvent, handleSpecialFocusKeys, } } function getFocusableElements(ref: React.RefObject) { const focusableElements = Array.from( document.querySelectorAll( "[tabindex], a, button, input, select, textarea" ) ) const currentElementIndex = focusableElements.indexOf(ref.current!) const shortcuts = currentElementIndex > 0 ? focusableElements[currentElementIndex - 1] : null let cancel: HTMLElement | null = null for (let i = currentElementIndex + 1; i < focusableElements.length; i++) { if (!ref.current!.contains(focusableElements[i])) { cancel = focusableElements[i] break } } return { shortcuts, cancel } }