feat(base): keyboard-navigate the row-number column for selection

This commit is contained in:
Philipinho
2026-06-15 03:59:03 +01:00
parent 84faf187de
commit cc951ae883
6 changed files with 95 additions and 14 deletions
@@ -125,7 +125,7 @@ export function GridContainer({
const editingCellRef = useRef(editingCell); const editingCellRef = useRef(editingCell);
editingCellRef.current = editingCell; editingCellRef.current = editingCell;
const { selectionCount, clear: clearSelection } = useRowSelection(pageId); const { selectionCount, clear: clearSelection, toggle: toggleRow } = useRowSelection(pageId);
const { deleteSelected } = useDeleteSelectedRows(pageId); const { deleteSelected } = useDeleteSelectedRows(pageId);
const { t } = useTranslation(); const { t } = useTranslation();
@@ -323,6 +323,17 @@ export function GridContainer({
[editable, properties, setPendingTypeInsert, setEditingCell, openEditor], [editable, properties, setPendingTypeInsert, setEditingCell, openEditor],
); );
const toggleRowSelection = useCallback(
(rowId: string) => {
toggleRow(rowId, {
shiftKey: false,
rowIndex: rowIdsRef.current.indexOf(rowId),
orderedRowIds: rowIdsRef.current,
});
},
[toggleRow],
);
const prevEditingRef = useRef(editingCell); const prevEditingRef = useRef(editingCell);
useEffect(() => { useEffect(() => {
const prev = prevEditingRef.current; const prev = prevEditingRef.current;
@@ -373,6 +384,7 @@ export function GridContainer({
selectionCount, selectionCount,
clearSelection, clearSelection,
deleteSelected, deleteSelected,
toggleRowSelection,
}); });
const activeCell = editingCell ?? focusedCell; const activeCell = editingCell ?? focusedCell;
@@ -57,10 +57,12 @@ export const GridHeaderCell = memo(function GridHeaderCell({
const isRowNumber = header.column.id === "__row_number"; const isRowNumber = header.column.id === "__row_number";
const isPinned = header.column.getIsPinned(); const isPinned = header.column.getIsPinned();
const pinOffset = isPinned ? header.column.getStart("left") : undefined; const pinOffset = isPinned ? header.column.getStart("left") : undefined;
const { selectionCount } = useRowSelection(pageId); const { selectionCount, toggleAll } = useRowSelection(pageId);
const hasSelection = selectionCount > 0; const hasSelection = selectionCount > 0;
const editable = useBaseEditable(); const editable = useBaseEditable();
const isHeaderInteractive = editable && !!property && !isRowNumber; const isHeaderInteractive = editable && !!property && !isRowNumber;
const isRowNumberHeaderInteractive =
isRowNumber && editable && loadedRowIds.length > 0;
const [activePropertyMenu, setActivePropertyMenu] = useAtom(activePropertyMenuAtomFamily(pageId)) as unknown as [string | null, (val: string | null) => void]; const [activePropertyMenu, setActivePropertyMenu] = useAtom(activePropertyMenuAtomFamily(pageId)) as unknown as [string | null, (val: string | null) => void];
const menuOpened = activePropertyMenu === header.column.id; const menuOpened = activePropertyMenu === header.column.id;
@@ -209,9 +211,10 @@ export const GridHeaderCell = memo(function GridHeaderCell({
return ( return (
<div <div
ref={cellRef} ref={cellRef}
role={isRowNumber ? undefined : "columnheader"} role="columnheader"
tabIndex={isHeaderInteractive ? 0 : undefined} tabIndex={isHeaderInteractive || isRowNumberHeaderInteractive ? 0 : undefined}
aria-haspopup={isHeaderInteractive ? "menu" : undefined} aria-haspopup={isHeaderInteractive ? "menu" : undefined}
aria-label={isRowNumberHeaderInteractive ? t("Select all loaded rows") : undefined}
className={`${classes.headerCell} ${isPinned ? classes.headerCellPinned : ""} ${hasSelection ? classes.hasSelection : ""}`} className={`${classes.headerCell} ${isPinned ? classes.headerCellPinned : ""} ${hasSelection ? classes.hasSelection : ""}`}
style={{ style={{
...(isPinned ...(isPinned
@@ -227,7 +230,11 @@ export const GridHeaderCell = memo(function GridHeaderCell({
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") { if (e.key === "Enter" || e.key === " ") {
e.preventDefault(); e.preventDefault();
handleHeaderClick(); if (isRowNumber) {
if (isRowNumberHeaderInteractive) toggleAll(loadedRowIds);
} else {
handleHeaderClick();
}
} }
}} }}
data-dragging={isDragging || undefined} data-dragging={isDragging || undefined}
@@ -1,7 +1,11 @@
import { memo, useCallback } from "react"; import { memo, useCallback, useMemo } from "react";
import { Checkbox } from "@mantine/core"; import { Checkbox } from "@mantine/core";
import { IconGripVertical } from "@tabler/icons-react"; import { IconGripVertical } from "@tabler/icons-react";
import { useAtomValue, useSetAtom, type PrimitiveAtom } from "jotai";
import { selectAtom } from "jotai/utils";
import { useRowSelection } from "@/ee/base/hooks/use-row-selection"; import { useRowSelection } from "@/ee/base/hooks/use-row-selection";
import { focusedCellAtomFamily } from "@/ee/base/atoms/base-atoms";
import { FocusedCell } from "@/ee/base/types/base.types";
import { useBaseEditable } from "@/ee/base/context/base-editable"; import { useBaseEditable } from "@/ee/base/context/base-editable";
import { useGridRowOrder } from "@/ee/base/context/grid-row-order"; import { useGridRowOrder } from "@/ee/base/context/grid-row-order";
import classes from "@/ee/base/styles/grid.module.css"; import classes from "@/ee/base/styles/grid.module.css";
@@ -26,6 +30,30 @@ export const RowNumberCell = memo(function RowNumberCell({
const editable = useBaseEditable(); const editable = useBaseEditable();
const getOrderedRowIds = useGridRowOrder(); const getOrderedRowIds = useGridRowOrder();
const setFocusedCell = useSetAtom(
focusedCellAtomFamily(pageId) as PrimitiveAtom<FocusedCell>,
);
const isFocused = useAtomValue(
useMemo(
() =>
selectAtom(
focusedCellAtomFamily(pageId),
(fc) => fc?.rowId === rowId && fc?.propertyId === "__row_number",
),
[pageId, rowId],
),
);
const handleCellClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
setFocusedCell({ rowId, propertyId: "__row_number" });
(e.currentTarget.closest('[role="grid"]') as HTMLElement | null)?.focus({
preventScroll: true,
});
},
[rowId, setFocusedCell],
);
const handleCheckboxChange = useCallback( const handleCheckboxChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => { (e: React.ChangeEvent<HTMLInputElement>) => {
const nativeEvent = e.nativeEvent as MouseEvent; const nativeEvent = e.nativeEvent as MouseEvent;
@@ -40,12 +68,15 @@ export const RowNumberCell = memo(function RowNumberCell({
return ( return (
<div <div
className={`${classes.cell} ${classes.rowNumberCell} ${isPinned ? classes.cellPinned : ""}`} id={`base-cell-${rowId}-__row_number`}
role="gridcell"
className={`${classes.cell} ${classes.rowNumberCell} ${isPinned ? classes.cellPinned : ""} ${isFocused ? classes.cellFocused : ""}`}
style={ style={
isPinned isPinned
? ({ "--pin-offset": `${pinOffset ?? 0}px` } as React.CSSProperties) ? ({ "--pin-offset": `${pinOffset ?? 0}px` } as React.CSSProperties)
: undefined : undefined
} }
onClick={handleCellClick}
> >
<div className={classes.rowNumberCellInner}> <div className={classes.rowNumberCellInner}>
{editable && ( {editable && (
@@ -60,6 +91,7 @@ export const RowNumberCell = memo(function RowNumberCell({
checked={selected} checked={selected}
onChange={handleCheckboxChange} onChange={handleCheckboxChange}
aria-label="Select row" aria-label="Select row"
tabIndex={-1}
/> />
</span> </span>
)} )}
@@ -42,6 +42,7 @@ export const RowNumberHeaderCell = memo(function RowNumberHeaderCell({
indeterminate={indeterminate} indeterminate={indeterminate}
onChange={() => toggleAll(loadedRowIds)} onChange={() => toggleAll(loadedRowIds)}
aria-label="Select all loaded rows" aria-label="Select all loaded rows"
tabIndex={-1}
/> />
</Tooltip> </Tooltip>
</span> </span>
@@ -24,6 +24,7 @@ type UseGridKeyboardNavOptions = {
selectionCount: number; selectionCount: number;
clearSelection: () => void; clearSelection: () => void;
deleteSelected: () => void | Promise<void>; deleteSelected: () => void | Promise<void>;
toggleRowSelection: (rowId: string) => void;
}; };
const isPrintableKey = (e: KeyboardEvent) => const isPrintableKey = (e: KeyboardEvent) =>
@@ -50,6 +51,7 @@ export function useGridKeyboardNav({
selectionCount, selectionCount,
clearSelection, clearSelection,
deleteSelected, deleteSelected,
toggleRowSelection,
}: UseGridKeyboardNavOptions) { }: UseGridKeyboardNavOptions) {
const getColIds = useCallback( const getColIds = useCallback(
() => () =>
@@ -60,6 +62,11 @@ export function useGridKeyboardNav({
[table], [table],
); );
const getNavColIds = useCallback(
() => table.getVisibleLeafColumns().map((col) => col.id),
[table],
);
const getRowIds = useCallback( const getRowIds = useCallback(
() => table.getRowModel().rows.map((row) => row.id), () => table.getRowModel().rows.map((row) => row.id),
[table], [table],
@@ -187,35 +194,35 @@ export function useGridKeyboardNav({
case "ArrowUp": case "ArrowUp":
e.preventDefault(); e.preventDefault();
{ {
const next = computeNextCell(getRowIds(), getColIds(), focusedCell, -1, 0, false); const next = computeNextCell(getRowIds(), getNavColIds(), focusedCell, -1, 0, false);
if (next) goFocused(next); if (next) goFocused(next);
} }
break; break;
case "ArrowDown": case "ArrowDown":
e.preventDefault(); e.preventDefault();
{ {
const next = computeNextCell(getRowIds(), getColIds(), focusedCell, 1, 0, false); const next = computeNextCell(getRowIds(), getNavColIds(), focusedCell, 1, 0, false);
if (next) goFocused(next); if (next) goFocused(next);
} }
break; break;
case "ArrowLeft": case "ArrowLeft":
e.preventDefault(); e.preventDefault();
{ {
const next = computeNextCell(getRowIds(), getColIds(), focusedCell, 0, -1, false); const next = computeNextCell(getRowIds(), getNavColIds(), focusedCell, 0, -1, false);
if (next) goFocused(next); if (next) goFocused(next);
} }
break; break;
case "ArrowRight": case "ArrowRight":
e.preventDefault(); e.preventDefault();
{ {
const next = computeNextCell(getRowIds(), getColIds(), focusedCell, 0, 1, false); const next = computeNextCell(getRowIds(), getNavColIds(), focusedCell, 0, 1, false);
if (next) goFocused(next); if (next) goFocused(next);
} }
break; break;
case "Tab": { case "Tab": {
const next = computeNextCell( const next = computeNextCell(
getRowIds(), getRowIds(),
getColIds(), getNavColIds(),
focusedCell, focusedCell,
0, 0,
e.shiftKey ? -1 : 1, e.shiftKey ? -1 : 1,
@@ -230,10 +237,17 @@ export function useGridKeyboardNav({
case "Enter": case "Enter":
case "F2": case "F2":
e.preventDefault(); e.preventDefault();
openEditor(focusedCell); if (focusedCell.propertyId === "__row_number") {
toggleRowSelection(focusedCell.rowId);
} else {
openEditor(focusedCell);
}
break; break;
default: { default: {
if (e.key === " " && propertyType(focusedCell.propertyId) === "checkbox") { if (e.key === " " && focusedCell.propertyId === "__row_number") {
e.preventDefault();
toggleRowSelection(focusedCell.rowId);
} else if (e.key === " " && propertyType(focusedCell.propertyId) === "checkbox") {
e.preventDefault(); e.preventDefault();
openEditor(focusedCell); openEditor(focusedCell);
} else if (isPrintableKey(e)) { } else if (isPrintableKey(e)) {
@@ -248,6 +262,7 @@ export function useGridKeyboardNav({
focusedCell, focusedCell,
getRowIds, getRowIds,
getColIds, getColIds,
getNavColIds,
goEditing, goEditing,
goFocused, goFocused,
setEditingCell, setEditingCell,
@@ -259,6 +274,7 @@ export function useGridKeyboardNav({
selectionCount, selectionCount,
clearSelection, clearSelection,
deleteSelected, deleteSelected,
toggleRowSelection,
], ],
); );
@@ -498,6 +498,12 @@
.rowSelected .rowNumberCheckbox { .rowSelected .rowNumberCheckbox {
display: inline-flex; display: inline-flex;
} }
.bodyGrid:focus-within .cellFocused .rowNumberIndex {
display: none;
}
.bodyGrid:focus-within .cellFocused .rowNumberCheckbox {
display: inline-flex;
}
.rowSelected .cell { .rowSelected .cell {
background: light-dark(var(--mantine-color-blue-0), var(--mantine-color-dark-6)); background: light-dark(var(--mantine-color-blue-0), var(--mantine-color-dark-6));
} }
@@ -534,6 +540,13 @@
display: inline-flex; display: inline-flex;
} }
.headerCell:focus-visible .rowNumberHeaderHash {
display: none;
}
.headerCell:focus-visible .rowNumberHeaderCheckbox {
display: inline-flex;
}
.selectionActionBarWrapper { .selectionActionBarWrapper {
position: fixed; position: fixed;
left: 50%; left: 50%;