diff --git a/apps/client/src/ee/base/components/grid/grid-container.tsx b/apps/client/src/ee/base/components/grid/grid-container.tsx index 2d11f2e8a..ee0d9d938 100644 --- a/apps/client/src/ee/base/components/grid/grid-container.tsx +++ b/apps/client/src/ee/base/components/grid/grid-container.tsx @@ -7,8 +7,23 @@ import { windowScroll, } from "@tanstack/react-virtual"; import { useAtom } from "jotai"; -import { IBaseRow, IBaseProperty, EditingCell } from "@/ee/base/types/base.types"; -import { editingCellAtomFamily } from "@/ee/base/atoms/base-atoms"; +import { + IBaseRow, + IBaseProperty, + EditingCell, + FocusedCell, + CellCoord, +} from "@/ee/base/types/base.types"; +import { + editingCellAtomFamily, + focusedCellAtomFamily, + activeFormulaEditorAtomFamily, + pendingTypeInsertAtom, + type FormulaEditorTarget, + type PendingTypeInsert, +} from "@/ee/base/atoms/base-atoms"; +import { isSystemPropertyType } from "@/ee/base/property-types/property-type.registry"; +import { useTranslation } from "react-i18next"; import { useColumnResize } from "@/ee/base/hooks/use-column-resize"; import { useGridKeyboardNav } from "@/ee/base/hooks/use-grid-keyboard-nav"; import { useRowAutoScroll } from "@/ee/base/hooks/use-row-autoscroll"; @@ -113,6 +128,14 @@ export function GridContainer({ const { selectionCount, clear: clearSelection } = useRowSelection(pageId); const { deleteSelected } = useDeleteSelectedRows(pageId); + const { t } = useTranslation(); + + const [focusedCell, setFocusedCell] = useAtom(focusedCellAtomFamily(pageId)) as unknown as [FocusedCell, (val: FocusedCell) => void]; + const focusedCellRef = useRef(focusedCell); + focusedCellRef.current = focusedCell; + const [, setActiveFormulaEditor] = useAtom(activeFormulaEditorAtomFamily(pageId)) as unknown as [FormulaEditorTarget, (val: FormulaEditorTarget) => void]; + const [, setPendingTypeInsert] = useAtom(pendingTypeInsertAtom) as unknown as [PendingTypeInsert, (val: PendingTypeInsert) => void]; + useEffect(() => { const handleMouseDown = (e: MouseEvent) => { // Only act while an inline cell editor is open. Popover-based cells @@ -141,12 +164,6 @@ export function GridContainer({ useColumnResize(table, onResizeEnd ?? (() => {})); - useGridKeyboardNav({ - table, - editingCell, - setEditingCell, - containerRef: bodyRef, - }); // When the scroll container is the window (inline embed mode), the default // Element-mode observers read scrollTop/scrollLeft, which Window does not @@ -226,6 +243,143 @@ export function GridContainer({ const virtualItems = virtualizer.getVirtualItems(); + const pinnedLeftWidth = useCallback( + () => + table + .getVisibleLeafColumns() + .filter((c) => c.getIsPinned() === "left") + .reduce((sum, c) => sum + c.getSize(), 0), + [table], + ); + + const scrollCellIntoView = useCallback( + (coord: CellCoord, rowIndex: number) => { + if (rowIndex >= 0) virtualizer.scrollToIndex(rowIndex, { align: "auto" }); + requestAnimationFrame(() => { + const scroller = bodyRef.current; + const el = document.getElementById( + `base-cell-${coord.rowId}-${coord.propertyId}`, + ); + if (!scroller || !el) return; + const cellRect = el.getBoundingClientRect(); + const scRect = scroller.getBoundingClientRect(); + const pinned = pinnedLeftWidth(); + if (cellRect.left < scRect.left + pinned) { + scroller.scrollLeft -= scRect.left + pinned - cellRect.left; + } else if (cellRect.right > scRect.right) { + scroller.scrollLeft += cellRect.right - scRect.right; + } + }); + }, + [virtualizer, pinnedLeftWidth], + ); + + const openEditor = useCallback( + (coord: CellCoord) => { + const prop = properties.find((p) => p.id === coord.propertyId); + if (!prop) return; + if (prop.type === "checkbox") { + if (!editable) return; + const current = table.getRow(coord.rowId, true)?.getValue(coord.propertyId); + onCellUpdate(coord.rowId, coord.propertyId, !current); + return; + } + if (!editable) { + if (prop.type === "file") setEditingCell(coord); + return; + } + if (prop.type === "formula") { + setActiveFormulaEditor({ propertyId: coord.propertyId, rowId: coord.rowId }); + return; + } + if (isSystemPropertyType(prop.type)) return; + setEditingCell(coord); + }, + [properties, editable, table, onCellUpdate, setEditingCell, setActiveFormulaEditor], + ); + + const clearCell = useCallback( + (coord: CellCoord) => { + if (!editable) return; + const prop = properties.find((p) => p.id === coord.propertyId); + if (!prop || isSystemPropertyType(prop.type)) return; + onCellUpdate(coord.rowId, coord.propertyId, null); + }, + [editable, properties, onCellUpdate], + ); + + const beginTypeToEdit = useCallback( + (coord: CellCoord, char: string) => { + if (!editable) return; + const prop = properties.find((p) => p.id === coord.propertyId); + if (!prop || isSystemPropertyType(prop.type) || prop.type === "checkbox") return; + if (["text", "number", "url", "email"].includes(prop.type)) { + setPendingTypeInsert({ rowId: coord.rowId, propertyId: coord.propertyId, char }); + setEditingCell(coord); + } else { + openEditor(coord); + } + }, + [editable, properties, setPendingTypeInsert, setEditingCell, openEditor], + ); + + const prevEditingRef = useRef(editingCell); + useEffect(() => { + const prev = prevEditingRef.current; + prevEditingRef.current = editingCell; + if (prev && !editingCell) { + if (!focusedCellRef.current) setFocusedCell(prev); + const grid = bodyRef.current; + const active = document.activeElement; + if (grid && active && !grid.contains(active)) { + grid.focus({ preventScroll: true }); + } + } + }, [editingCell, setFocusedCell]); + + useEffect(() => { + const fc = focusedCellRef.current; + if (!fc) return; + const rowOk = rowIds.includes(fc.rowId); + const colOk = table.getVisibleLeafColumns().some((c) => c.id === fc.propertyId); + if (!rowOk || !colOk) setFocusedCell(null); + }, [rowIds, table.getState().columnVisibility, table.getState().columnOrder, setFocusedCell]); + + const handleGridFocus = useCallback( + (e: React.FocusEvent) => { + if (e.target !== e.currentTarget) return; + if (editingCellRef.current || focusedCellRef.current) return; + const firstRow = rowIdsRef.current[0]; + const firstCol = table + .getVisibleLeafColumns() + .find((c) => c.id !== "__row_number")?.id; + if (firstRow && firstCol) setFocusedCell({ rowId: firstRow, propertyId: firstCol }); + }, + [table, setFocusedCell], + ); + + useGridKeyboardNav({ + table, + properties, + containerRef: bodyRef, + focusedCell, + setFocusedCell, + editingCell, + setEditingCell, + openEditor, + clearCell, + beginTypeToEdit, + scrollCellIntoView, + selectionCount, + clearSelection, + deleteSelected, + }); + + const activeCell = editingCell ?? focusedCell; + const activeDescendantId = activeCell + ? `base-cell-${activeCell.rowId}-${activeCell.propertyId}` + : undefined; + useEffect(() => { if (!hasNextPage || isFetchingNextPage || !onFetchNextPage) return; const lastItem = virtualItems[virtualItems.length - 1]; @@ -244,29 +398,6 @@ export function GridContainer({ } }, [rows.length]); - useEffect(() => { - const el = bodyRef.current; - if (!el || !pageId) return; - const handler = (e: KeyboardEvent) => { - if (editingCell) return; - const active = document.activeElement as HTMLElement | null; - if (!active || !el.contains(active)) return; - const tag = active.tagName; - if (tag === "INPUT" || tag === "TEXTAREA" || active.isContentEditable) { - return; - } - if (e.key === "Escape" && selectionCount > 0) { - clearSelection(); - return; - } - if ((e.key === "Delete" || e.key === "Backspace") && selectionCount > 0) { - e.preventDefault(); - void deleteSelected(); - } - }; - el.addEventListener("keydown", handler); - return () => el.removeEventListener("keydown", handler); - }, [editingCell, selectionCount, clearSelection, deleteSelected, pageId]); const gridTemplateColumns = useMemo(() => { const visibleColumns = table.getVisibleLeafColumns(); @@ -314,7 +445,7 @@ export function GridContainer({ ); return ( -
+
{aboveBand}
; + properties: IBaseProperty[]; + containerRef: React.RefObject; + focusedCell: FocusedCell; + setFocusedCell: (cell: FocusedCell) => void; editingCell: EditingCell; setEditingCell: (cell: EditingCell) => void; - containerRef: React.RefObject; + openEditor: (coord: CellCoord) => void; + clearCell: (coord: CellCoord) => void; + beginTypeToEdit: (coord: CellCoord, char: string) => void; + scrollCellIntoView: (coord: CellCoord, rowIndex: number) => void; + selectionCount: number; + clearSelection: () => void; + deleteSelected: () => void | Promise; }; +const isPrintableKey = (e: KeyboardEvent) => + e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey; + +const isTextEntry = (el: Element | null) => + !!el && + (el.tagName === "INPUT" || + el.tagName === "TEXTAREA" || + (el as HTMLElement).isContentEditable); + export function useGridKeyboardNav({ table, + properties, + containerRef, + focusedCell, + setFocusedCell, editingCell, setEditingCell, - containerRef, + openEditor, + clearCell, + beginTypeToEdit, + scrollCellIntoView, + selectionCount, + clearSelection, + deleteSelected, }: UseGridKeyboardNavOptions) { - const getNavigableColumns = useCallback(() => { - return table - .getVisibleLeafColumns() - .filter((col) => col.id !== "__row_number") - .map((col) => col.id); - }, [table]); + const getColIds = useCallback( + () => + table + .getVisibleLeafColumns() + .filter((col) => col.id !== "__row_number") + .map((col) => col.id), + [table], + ); - const getRowIds = useCallback(() => { - return table.getRowModel().rows.map((row) => row.id); - }, [table]); + const getRowIds = useCallback( + () => table.getRowModel().rows.map((row) => row.id), + [table], + ); - const navigate = useCallback( - (rowDelta: number, colDelta: number) => { - if (!editingCell) return; + const propertyType = useCallback( + (propertyId: string) => properties.find((p) => p.id === propertyId)?.type, + [properties], + ); - const columns = getNavigableColumns(); - const rowIds = getRowIds(); - - const currentColIndex = columns.indexOf(editingCell.propertyId); - const currentRowIndex = rowIds.indexOf(editingCell.rowId); - - if (currentColIndex === -1 || currentRowIndex === -1) return; - - let nextColIndex = currentColIndex + colDelta; - let nextRowIndex = currentRowIndex + rowDelta; - - if (nextColIndex < 0) { - nextColIndex = columns.length - 1; - nextRowIndex -= 1; - } else if (nextColIndex >= columns.length) { - nextColIndex = 0; - nextRowIndex += 1; - } - - if (nextRowIndex < 0 || nextRowIndex >= rowIds.length) return; - - // Blur fires onBlur->commit before React unmounts the input + const goEditing = useCallback( + (next: CellCoord) => { (document.activeElement as HTMLElement | null)?.blur(); - - setEditingCell({ - rowId: rowIds[nextRowIndex], - propertyId: columns[nextColIndex], - }); + setEditingCell(next); + setFocusedCell(next); + scrollCellIntoView(next, getRowIds().indexOf(next.rowId)); }, - [editingCell, getNavigableColumns, getRowIds, setEditingCell], + [setEditingCell, setFocusedCell, scrollCellIntoView, getRowIds], + ); + + const goFocused = useCallback( + (next: CellCoord) => { + setFocusedCell(next); + scrollCellIntoView(next, getRowIds().indexOf(next.rowId)); + }, + [setFocusedCell, scrollCellIntoView, getRowIds], ); const handleKeyDown = useCallback( (e: KeyboardEvent) => { - if (!editingCell) return; + if (editingCell) { + const inInput = isTextEntry(e.target as Element); + switch (e.key) { + case "ArrowUp": + case "ArrowDown": + case "ArrowLeft": + case "ArrowRight": { + if (inInput) return; + e.preventDefault(); + const d = + e.key === "ArrowUp" + ? [-1, 0] + : e.key === "ArrowDown" + ? [1, 0] + : e.key === "ArrowLeft" + ? [0, -1] + : [0, 1]; + const next = computeNextCell( + getRowIds(), + getColIds(), + editingCell, + d[0], + d[1], + false, + ); + if (next) goEditing(next); + break; + } + case "Tab": { + e.preventDefault(); + const next = computeNextCell( + getRowIds(), + getColIds(), + editingCell, + 0, + e.shiftKey ? -1 : 1, + true, + ); + if (next) goEditing(next); + break; + } + case "Enter": { + e.preventDefault(); + const next = computeNextCell( + getRowIds(), + getColIds(), + editingCell, + 1, + 0, + false, + ); + (document.activeElement as HTMLElement | null)?.blur(); + setEditingCell(null); + if (next) goFocused(next); + else setFocusedCell(editingCell); + break; + } + case "Escape": { + e.preventDefault(); + (document.activeElement as HTMLElement | null)?.blur(); + setEditingCell(null); + setFocusedCell(editingCell); + break; + } + } + return; + } - const target = e.target as HTMLElement; - const isInputActive = - target.tagName === "INPUT" || - target.tagName === "TEXTAREA" || - target.isContentEditable; + if (isTextEntry(document.activeElement)) return; + + if (e.key === "Escape") { + if (selectionCount > 0) { + e.preventDefault(); + clearSelection(); + } else if (focusedCell) { + e.preventDefault(); + setFocusedCell(null); + } + return; + } + + if (e.key === "Delete" || e.key === "Backspace") { + if (selectionCount > 0) { + e.preventDefault(); + void deleteSelected(); + } else if (focusedCell) { + e.preventDefault(); + clearCell(focusedCell); + } + return; + } + + if (!focusedCell) return; switch (e.key) { case "ArrowUp": - if (!isInputActive) { - e.preventDefault(); - navigate(-1, 0); + e.preventDefault(); + { + const next = computeNextCell(getRowIds(), getColIds(), focusedCell, -1, 0, false); + if (next) goFocused(next); } break; case "ArrowDown": - if (!isInputActive) { - e.preventDefault(); - navigate(1, 0); + e.preventDefault(); + { + const next = computeNextCell(getRowIds(), getColIds(), focusedCell, 1, 0, false); + if (next) goFocused(next); } break; case "ArrowLeft": - if (!isInputActive) { - e.preventDefault(); - navigate(0, -1); + e.preventDefault(); + { + const next = computeNextCell(getRowIds(), getColIds(), focusedCell, 0, -1, false); + if (next) goFocused(next); } break; case "ArrowRight": - if (!isInputActive) { - e.preventDefault(); - navigate(0, 1); + e.preventDefault(); + { + const next = computeNextCell(getRowIds(), getColIds(), focusedCell, 0, 1, false); + if (next) goFocused(next); } break; - case "Tab": - e.preventDefault(); - navigate(0, e.shiftKey ? -1 : 1); + case "Tab": { + const next = computeNextCell( + getRowIds(), + getColIds(), + focusedCell, + 0, + e.shiftKey ? -1 : 1, + true, + ); + if (next) { + e.preventDefault(); + goFocused(next); + } break; - case "Escape": + } + case "Enter": + case "F2": e.preventDefault(); - setEditingCell(null); + openEditor(focusedCell); break; + default: { + if (e.key === " " && propertyType(focusedCell.propertyId) === "checkbox") { + e.preventDefault(); + openEditor(focusedCell); + } else if (isPrintableKey(e)) { + e.preventDefault(); + beginTypeToEdit(focusedCell, e.key); + } + } } }, - [editingCell, navigate, setEditingCell], + [ + editingCell, + focusedCell, + getRowIds, + getColIds, + goEditing, + goFocused, + setEditingCell, + setFocusedCell, + openEditor, + clearCell, + beginTypeToEdit, + propertyType, + selectionCount, + clearSelection, + deleteSelected, + ], ); useEffect(() => { - const container = containerRef.current; - if (!container) return; - - container.addEventListener("keydown", handleKeyDown); - return () => container.removeEventListener("keydown", handleKeyDown); + const el = containerRef.current; + if (!el) return; + el.addEventListener("keydown", handleKeyDown); + return () => el.removeEventListener("keydown", handleKeyDown); }, [containerRef, handleKeyDown]); }