fix grid cells on mobile

This commit is contained in:
Philipinho
2026-06-18 23:06:43 +01:00
parent 0b7bc70a7d
commit 8685c32249
5 changed files with 79 additions and 109 deletions
@@ -1,4 +1,5 @@
import { memo, useCallback, useMemo, useRef } from "react"; import { memo, useCallback, useMemo } from "react";
import { flushSync } from "react-dom";
import { Cell } from "@tanstack/react-table"; import { Cell } from "@tanstack/react-table";
import { Popover, Tooltip } from "@mantine/core"; import { Popover, Tooltip } from "@mantine/core";
import { IconArrowsDiagonal } from "@tabler/icons-react"; import { IconArrowsDiagonal } from "@tabler/icons-react";
@@ -24,8 +25,6 @@ import { useRowExpand } from "@/ee/base/context/row-expand";
import { RowNumberCell } from "./row-number-cell"; import { RowNumberCell } from "./row-number-cell";
import classes from "@/ee/base/styles/grid.module.css"; import classes from "@/ee/base/styles/grid.module.css";
const TOUCH_TAP_SLOP_PX = 10;
type GridCellProps = { type GridCellProps = {
cell: Cell<IBaseRow, unknown>; cell: Cell<IBaseRow, unknown>;
rowIndex: number; rowIndex: number;
@@ -74,18 +73,14 @@ export const GridCell = memo(function GridCell({
editingCell?.propertyId === property?.id && editingCell?.propertyId === property?.id &&
(editable || property?.type === "file"); (editable || property?.type === "file");
const tapStartRef = useRef<{ x: number; y: number } | null>(null); const handleEdit = useCallback(() => {
const suppressClickRef = useRef(false);
const expandClickGuardRef = useRef(false);
const handleDoubleClick = useCallback(() => {
if (!property || isRowNumber) return; if (!property || isRowNumber) return;
if (property.type === "checkbox") return; if (property.type === "checkbox") return;
if (readOnly) { if (readOnly) {
// Read-only: only the file cell opens (a download-only popover) so // Read-only: only the file cell opens (a download-only popover) so
// attachments stay reachable. // attachments stay reachable.
if (property.type === "file") { if (property.type === "file") {
setEditingCell({ rowId, propertyId: property.id }); flushSync(() => setEditingCell({ rowId, propertyId: property.id }));
} }
return; return;
} }
@@ -94,7 +89,7 @@ export const GridCell = memo(function GridCell({
return; return;
} }
if (isSystemPropertyType(property.type)) return; if (isSystemPropertyType(property.type)) return;
setEditingCell({ rowId, propertyId: property.id }); flushSync(() => setEditingCell({ rowId, propertyId: property.id }));
}, [property, isRowNumber, rowId, readOnly, setEditingCell, setActiveFormulaEditor]); }, [property, isRowNumber, rowId, readOnly, setEditingCell, setActiveFormulaEditor]);
const handleMouseDown = useCallback( const handleMouseDown = useCallback(
@@ -108,10 +103,6 @@ export const GridCell = memo(function GridCell({
const handleClick = useCallback( const handleClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => { (e: React.MouseEvent<HTMLDivElement>) => {
if (!property) return; if (!property) return;
if (suppressClickRef.current) {
suppressClickRef.current = false;
return;
}
setFocusedCell({ rowId, propertyId: property.id }); setFocusedCell({ rowId, propertyId: property.id });
(e.currentTarget.closest('[role="grid"]') as HTMLElement | null)?.focus({ (e.currentTarget.closest('[role="grid"]') as HTMLElement | null)?.focus({
preventScroll: true, preventScroll: true,
@@ -120,46 +111,6 @@ export const GridCell = memo(function GridCell({
[property, rowId, setFocusedCell], [property, rowId, setFocusedCell],
); );
const handlePointerDown = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
expandClickGuardRef.current = false;
if (e.pointerType === "mouse") return;
suppressClickRef.current = false;
tapStartRef.current = { x: e.clientX, y: e.clientY };
},
[],
);
const handlePointerUp = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (e.pointerType === "mouse") return;
const start = tapStartRef.current;
tapStartRef.current = null;
if (!start) return;
if (
Math.abs(e.clientX - start.x) > TOUCH_TAP_SLOP_PX ||
Math.abs(e.clientY - start.y) > TOUCH_TAP_SLOP_PX
) {
return;
}
const target = e.target as HTMLElement;
if (onExpandRow && target.closest("[data-base-row-expand]")) {
suppressClickRef.current = true;
expandClickGuardRef.current = true;
onExpandRow(rowId);
return;
}
if (target.closest("button, a, input")) return;
suppressClickRef.current = true;
handleDoubleClick();
},
[handleDoubleClick, onExpandRow, rowId],
);
const handlePointerCancel = useCallback(() => {
tapStartRef.current = null;
}, []);
const cellReadOnly = property const cellReadOnly = property
? readOnly || isSystemPropertyType(property.type) ? readOnly || isSystemPropertyType(property.type)
: false; : false;
@@ -249,10 +200,7 @@ export const GridCell = memo(function GridCell({
} }
onClick={handleClick} onClick={handleClick}
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
onDoubleClick={handleDoubleClick} onDoubleClick={handleEdit}
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerCancel}
> >
<CellComponent <CellComponent
value={value} value={value}
@@ -273,13 +221,7 @@ export const GridCell = memo(function GridCell({
tabIndex={-1} tabIndex={-1}
data-base-row-expand="" data-base-row-expand=""
className={classes.rowExpandButton} className={classes.rowExpandButton}
onClick={() => { onClick={() => onExpandRow(rowId)}
if (expandClickGuardRef.current) {
expandClickGuardRef.current = false;
return;
}
onExpandRow(rowId);
}}
onDoubleClick={(e) => e.stopPropagation()} onDoubleClick={(e) => e.stopPropagation()}
aria-label={t("Expand row {{number}}", { number: rowIndex + 1 })} aria-label={t("Expand row {{number}}", { number: rowIndex + 1 })}
> >
@@ -276,6 +276,12 @@ export function GridContainer({
[virtualizer, pinnedLeftWidth], [virtualizer, pinnedLeftWidth],
); );
useEffect(() => {
if (!editingCell) return;
const idx = rowIdsRef.current.indexOf(editingCell.rowId);
if (idx >= 0) scrollCellIntoView(editingCell, idx);
}, [editingCell, scrollCellIntoView]);
const openEditor = useCallback( const openEditor = useCallback(
(coord: CellCoord) => { (coord: CellCoord) => {
const prop = properties.find((p) => p.id === coord.propertyId); const prop = properties.find((p) => p.id === coord.propertyId);
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useLayoutEffect, useRef, useState } from "react";
import { useStore, type PrimitiveAtom } from "jotai"; import { useStore, type PrimitiveAtom } from "jotai";
import { pendingTypeInsertAtom, type PendingTypeInsert } from "@/ee/base/atoms/base-atoms"; import { pendingTypeInsertAtom, type PendingTypeInsert } from "@/ee/base/atoms/base-atoms";
@@ -41,7 +41,7 @@ export function useEditableTextCell({
toDraftRef.current = toDraft; toDraftRef.current = toDraft;
const store = useStore(); const store = useStore();
useEffect(() => { useLayoutEffect(() => {
if (isEditing && !wasEditingRef.current) { if (isEditing && !wasEditingRef.current) {
committedRef.current = false; committedRef.current = false;
const pending = store.get(pendingTypeInsertAtom); const pending = store.get(pendingTypeInsertAtom);
@@ -49,27 +49,21 @@ export function useEditableTextCell({
pending != null && pending != null &&
pending.rowId === rowId && pending.rowId === rowId &&
pending.propertyId === propertyId; pending.propertyId === propertyId;
const nextDraft = seeded ? pending.char : toDraftRef.current(value);
if (seeded) { if (seeded) {
setDraft(pending.char);
store.set(pendingTypeInsertAtom as PrimitiveAtom<PendingTypeInsert>, null); store.set(pendingTypeInsertAtom as PrimitiveAtom<PendingTypeInsert>, null);
requestAnimationFrame(() => { }
const el = inputRef.current; setDraft(nextDraft);
if (el) { const el = inputRef.current;
el.focus(); if (el) {
const len = el.value.length; el.value = nextDraft;
el.setSelectionRange(len, len); el.focus({ preventScroll: true });
} try {
}); el.setSelectionRange(nextDraft.length, nextDraft.length);
} else { } catch {
setDraft(toDraftRef.current(value)); // email/number inputs reject setSelectionRange
requestAnimationFrame(() => { }
const el = inputRef.current; el.scrollLeft = el.scrollWidth;
if (el) {
el.focus();
const len = el.value.length;
el.setSelectionRange(len, len);
}
});
} }
} }
wasEditingRef.current = isEditing; wasEditingRef.current = isEditing;
+50 -22
View File
@@ -184,11 +184,13 @@
light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4)); light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
} }
.row:hover .cell { @media (hover: hover) {
background-color: light-dark( .row:hover .cell {
var(--mantine-color-gray-0), background-color: light-dark(
var(--mantine-color-dark-7) var(--mantine-color-gray-0),
); var(--mantine-color-dark-7)
);
}
} }
.cell { .cell {
@@ -196,6 +198,7 @@
align-items: center; align-items: center;
min-height: 36px; min-height: 36px;
padding: 0 8px; padding: 0 8px;
touch-action: manipulation;
font-size: var(--mantine-font-size-sm); font-size: var(--mantine-font-size-sm);
color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0)); color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0));
background-color: light-dark( background-color: light-dark(
@@ -227,11 +230,13 @@
); );
} }
.row:hover .cellPinned { @media (hover: hover) {
background-color: light-dark( .row:hover .cellPinned {
var(--mantine-color-gray-0), background-color: light-dark(
var(--mantine-color-dark-7) var(--mantine-color-gray-0),
); var(--mantine-color-dark-7)
);
}
} }
.cellEditing { .cellEditing {
@@ -247,6 +252,14 @@
z-index: 1; z-index: 1;
} }
@media (hover: none) {
.cellFocused {
outline: 2px solid var(--mantine-color-blue-5);
outline-offset: -2px;
z-index: 1;
}
}
.headerCell:focus-visible { .headerCell:focus-visible {
outline: 2px solid var(--mantine-color-blue-5); outline: 2px solid var(--mantine-color-blue-5);
outline-offset: -2px; outline-offset: -2px;
@@ -470,6 +483,7 @@
cursor: pointer; cursor: pointer;
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
touch-action: manipulation;
transition: opacity 80ms ease, color 80ms ease; transition: opacity 80ms ease, color 80ms ease;
} }
@@ -486,17 +500,21 @@
} }
} }
.rowExpandButton:hover { @media (hover: hover) {
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5)); .rowExpandButton:hover {
color: light-dark(var(--mantine-color-blue-6), var(--mantine-color-blue-4)); background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
color: light-dark(var(--mantine-color-blue-6), var(--mantine-color-blue-4));
}
} }
.row:hover .rowNumberIndex { @media (hover: hover) {
display: none; .row:hover .rowNumberIndex {
} display: none;
.row:hover .rowNumberCheckbox, }
.row:hover .rowNumberDragHandle { .row:hover .rowNumberCheckbox,
display: inline-flex; .row:hover .rowNumberDragHandle {
display: inline-flex;
}
} }
.rowSelected .rowNumberIndex { .rowSelected .rowNumberIndex {
@@ -511,13 +529,23 @@
.bodyGrid:focus .cellFocused .rowNumberCheckbox { .bodyGrid:focus .cellFocused .rowNumberCheckbox {
display: inline-flex; display: inline-flex;
} }
@media (hover: none) {
.cellFocused .rowNumberIndex {
display: none;
}
.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));
} }
.row.rowSelected:hover .cell, @media (hover: hover) {
.row.rowSelected:hover .cellPinned { .row.rowSelected:hover .cell,
background-color: light-dark(var(--mantine-color-blue-1), var(--mantine-color-dark-5)); .row.rowSelected:hover .cellPinned {
background-color: light-dark(var(--mantine-color-blue-1), var(--mantine-color-dark-5));
}
} }
.rowNumberHeaderInner { .rowNumberHeaderInner {