import { useEffect, useLayoutEffect, useCallback, useMemo } from 'react'; import { flushSync } from 'react-dom'; import type { FiltersMap } from '@wix/bex-core'; import type { EditableTableState } from '../../state/EditableTable'; import { Focusable } from './types'; interface UseCellFocusAndEditingParams { state: EditableTableState; rowKey: string; columnId: string; cellRef: React.MutableRefObject; editInputRef: React.MutableRefObject; } export function useCellFocusAndEditing({ state, rowKey, columnId, cellRef, editInputRef, }: UseCellFocusAndEditingParams) { const { cellInteraction } = state; const column = state._columnMap.get(columnId); const resolved = column ? state.resolveCellTypeByColumnId(column.id) : null; const isFocused = cellInteraction.isCellFocused(rowKey, columnId); const isEditing = cellInteraction.isCellEditing(rowKey, columnId); const isEditable = state.isCellEditable(rowKey, columnId); // Register the focused cell's DOM element on the state so // scrollFocusedCellIntoView can use it. useLayoutEffect ensures // the element is set before the requestAnimationFrame in // scrollFocusedCellIntoView runs. useLayoutEffect(() => { if (isFocused) { cellInteraction.focusedCellElement = cellRef.current; } return () => { if (cellInteraction.focusedCellElement === cellRef.current) { cellInteraction.focusedCellElement = null; } }; }, [isFocused, cellRef, cellInteraction]); // When focused (not editing): focus the cell div so it receives keyboard events. // When editing starts: focus the input, then place caret at end in a setTimeout // so it runs after the browser's own selection (e.g. from autoSelect) settles. // Skip caret placement if the input already has a non-collapsed selection. useEffect(() => { if (!isFocused) { return; } if (isEditing) { if ( editInputRef.current && (document.activeElement as | HTMLInputElement | HTMLTextAreaElement | null) !== editInputRef.current ) { editInputRef.current.focus(); } setTimeout(() => { // Use document.activeElement rather than editInputRef.current because // editInputRef may point to a React component instance (e.g. InputArea) // rather than the underlying DOM element, which has no .value property. const input = document.activeElement as | HTMLInputElement | HTMLTextAreaElement | null; if (!input || !('selectionStart' in input)) { return; } if (input.selectionStart !== input.selectionEnd) { return; } const len = input.value?.length ?? 0; input.setSelectionRange(len, len); }, 0); } else { // Don't steal focus if a descendant already has it (e.g. image cell's // inner-focus buttons). The gridcell only needs focusing when it isn't // already within the active focus subtree. if (!cellRef.current?.contains(document.activeElement)) { cellRef.current?.focus({ preventScroll: true }); } } }, [isFocused, isEditing, cellRef, editInputRef]); const handleActivate = useCallback(() => { if (resolved?.toggleValue) { state.cellValue.toggleCell(rowKey, columnId); } else { cellInteraction.startEdit(); } }, [resolved, state, rowKey, columnId, cellInteraction]); // Handle Enter and type-to-edit at the cell level const onKeyDown = useCallback( (e: React.KeyboardEvent) => { if (!isFocused || !isEditable || isEditing) { return; } if (state.updateItemDisabled) { return; } if (e.key === 'Enter') { e.preventDefault(); if (resolved?.showEditWhenFocused) { // EditComponent is always visible. Focus into its first interactive // element (inner focus) rather than entering full edit mode now — // the cell type calls onStartEdit() itself right before any external // UI (media manager / modal) opens, which is the only time isEditing // needs to be true for useClearFocusOnBlur protection. editInputRef.current?.focus(); } else { handleActivate(); } return; } // Type-to-edit: prevent the browser's default text insertion, start // edit mode, then set the value programmatically via React state. // startEdit() must come before setEditingValue() because it resets // editingValue to undefined. if ( e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey && !e.nativeEvent.isComposing ) { // Cell-type-level gate: only cell types that define onTypeToEdit support // type-to-edit. Absence of onTypeToEdit is the natural opt-out (e.g. boolean). if (!resolved?.onTypeToEdit) { return; } // Key-level gate: onTypeToEdit returning undefined means "block this key" // (e.g. number blocks non-digit keys). const typeResult = resolved.onTypeToEdit({ key: e.key, validation: column?.validation, }); if (typeResult === undefined) { return; } if (resolved.nativeTypeToEdit) { // Synchronously enter edit mode and focus the input within the same // keydown event. The browser's default action then delivers the // character to the now-focused input — no programmatic seeding. // Mirrors cms-web's `focusOnTyping` pattern (table-cell-wrapper.tsx). flushSync(() => { cellInteraction.startEdit(undefined, undefined, { kind: 'type', key: e.key, }); if ('value' in typeResult) { cellInteraction.setEditingValue(typeResult.value); } }); editInputRef.current?.focus(); e.stopPropagation(); // Intentionally no preventDefault — the browser inserts the // character into the now-focused input naturally. return; } e.preventDefault(); cellInteraction.startEdit(undefined, undefined, { kind: 'type', key: e.key, }); if ('value' in typeResult) { // Cell type provides an explicit pre-populated value (e.g. first typed // char for text, or the digit for number). cellInteraction.setEditingValue(typeResult.value); } // onTypeToEdit returned {} (no value): enter edit mode with current value intact // (e.g. select/date open their picker without pre-populating). // Best-effort: editInputRef.current is null here because the Edit // component only mounts after isEditing flips. The useEffect above // is the reliable focus path once the component renders. editInputRef.current?.focus(); e.stopPropagation(); } }, [ editInputRef, isFocused, isEditable, isEditing, state, cellInteraction, resolved, handleActivate, ], ); return useMemo(() => ({ onKeyDown }), [onKeyDown]); }