feat(base): keyboard navigation controller and grid wiring

This commit is contained in:
Philipinho
2026-06-15 02:13:59 +01:00
parent b8f56fee03
commit f5831a256a
2 changed files with 394 additions and 104 deletions
@@ -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<HTMLDivElement>) => {
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 (
<div role="grid" style={GRID_ROOT_STYLE}>
<div style={GRID_ROOT_STYLE}>
{aboveBand}
<div className={classes.stickyBand}>
<div
@@ -341,6 +472,13 @@ export function GridContainer({
className={classes.bodyGrid}
ref={bodyRef}
tabIndex={0}
role="grid"
aria-label={t("Base table")}
aria-rowcount={rows.length}
aria-colcount={table.getVisibleLeafColumns().length}
aria-multiselectable
aria-activedescendant={activeDescendantId}
onFocus={handleGridFocus}
style={
{
"--base-grid-cols": bodyGridTemplateColumns,
@@ -1,120 +1,272 @@
import { useCallback, useEffect } from "react";
import { Table } from "@tanstack/react-table";
import { IBaseRow, EditingCell } from "@/ee/base/types/base.types";
import {
IBaseRow,
IBaseProperty,
EditingCell,
FocusedCell,
CellCoord,
} from "@/ee/base/types/base.types";
import { computeNextCell } from "@/ee/base/utils/grid-cell-nav";
type UseGridKeyboardNavOptions = {
table: Table<IBaseRow>;
properties: IBaseProperty[];
containerRef: React.RefObject<HTMLDivElement | null>;
focusedCell: FocusedCell;
setFocusedCell: (cell: FocusedCell) => void;
editingCell: EditingCell;
setEditingCell: (cell: EditingCell) => void;
containerRef: React.RefObject<HTMLDivElement | null>;
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<void>;
};
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]);
}