diff --git a/apps/client/src/ee/base/components/grid/grid-cell.tsx b/apps/client/src/ee/base/components/grid/grid-cell.tsx index f54687428..38de042f5 100644 --- a/apps/client/src/ee/base/components/grid/grid-cell.tsx +++ b/apps/client/src/ee/base/components/grid/grid-cell.tsx @@ -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 { Popover, Tooltip } from "@mantine/core"; 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 classes from "@/ee/base/styles/grid.module.css"; -const TOUCH_TAP_SLOP_PX = 10; - type GridCellProps = { cell: Cell; rowIndex: number; @@ -74,18 +73,14 @@ export const GridCell = memo(function GridCell({ editingCell?.propertyId === property?.id && (editable || property?.type === "file"); - const tapStartRef = useRef<{ x: number; y: number } | null>(null); - const suppressClickRef = useRef(false); - const expandClickGuardRef = useRef(false); - - const handleDoubleClick = useCallback(() => { + const handleEdit = useCallback(() => { if (!property || isRowNumber) return; if (property.type === "checkbox") return; if (readOnly) { // Read-only: only the file cell opens (a download-only popover) so // attachments stay reachable. if (property.type === "file") { - setEditingCell({ rowId, propertyId: property.id }); + flushSync(() => setEditingCell({ rowId, propertyId: property.id })); } return; } @@ -94,7 +89,7 @@ export const GridCell = memo(function GridCell({ return; } if (isSystemPropertyType(property.type)) return; - setEditingCell({ rowId, propertyId: property.id }); + flushSync(() => setEditingCell({ rowId, propertyId: property.id })); }, [property, isRowNumber, rowId, readOnly, setEditingCell, setActiveFormulaEditor]); const handleMouseDown = useCallback( @@ -108,10 +103,6 @@ export const GridCell = memo(function GridCell({ const handleClick = useCallback( (e: React.MouseEvent) => { if (!property) return; - if (suppressClickRef.current) { - suppressClickRef.current = false; - return; - } setFocusedCell({ rowId, propertyId: property.id }); (e.currentTarget.closest('[role="grid"]') as HTMLElement | null)?.focus({ preventScroll: true, @@ -120,46 +111,6 @@ export const GridCell = memo(function GridCell({ [property, rowId, setFocusedCell], ); - const handlePointerDown = useCallback( - (e: React.PointerEvent) => { - 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) => { - 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 ? readOnly || isSystemPropertyType(property.type) : false; @@ -249,10 +200,7 @@ export const GridCell = memo(function GridCell({ } onClick={handleClick} onMouseDown={handleMouseDown} - onDoubleClick={handleDoubleClick} - onPointerDown={handlePointerDown} - onPointerUp={handlePointerUp} - onPointerCancel={handlePointerCancel} + onDoubleClick={handleEdit} > { - if (expandClickGuardRef.current) { - expandClickGuardRef.current = false; - return; - } - onExpandRow(rowId); - }} + onClick={() => onExpandRow(rowId)} onDoubleClick={(e) => e.stopPropagation()} aria-label={t("Expand row {{number}}", { number: rowIndex + 1 })} > 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 52bb6580f..6a7f9622d 100644 --- a/apps/client/src/ee/base/components/grid/grid-container.tsx +++ b/apps/client/src/ee/base/components/grid/grid-container.tsx @@ -276,6 +276,12 @@ export function GridContainer({ [virtualizer, pinnedLeftWidth], ); + useEffect(() => { + if (!editingCell) return; + const idx = rowIdsRef.current.indexOf(editingCell.rowId); + if (idx >= 0) scrollCellIntoView(editingCell, idx); + }, [editingCell, scrollCellIntoView]); + const openEditor = useCallback( (coord: CellCoord) => { const prop = properties.find((p) => p.id === coord.propertyId); diff --git a/apps/client/src/ee/base/hooks/use-editable-text-cell.ts b/apps/client/src/ee/base/hooks/use-editable-text-cell.ts index ec1d10808..8fa5d934d 100644 --- a/apps/client/src/ee/base/hooks/use-editable-text-cell.ts +++ b/apps/client/src/ee/base/hooks/use-editable-text-cell.ts @@ -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 { pendingTypeInsertAtom, type PendingTypeInsert } from "@/ee/base/atoms/base-atoms"; @@ -41,7 +41,7 @@ export function useEditableTextCell({ toDraftRef.current = toDraft; const store = useStore(); - useEffect(() => { + useLayoutEffect(() => { if (isEditing && !wasEditingRef.current) { committedRef.current = false; const pending = store.get(pendingTypeInsertAtom); @@ -49,27 +49,21 @@ export function useEditableTextCell({ pending != null && pending.rowId === rowId && pending.propertyId === propertyId; + const nextDraft = seeded ? pending.char : toDraftRef.current(value); if (seeded) { - setDraft(pending.char); store.set(pendingTypeInsertAtom as PrimitiveAtom, null); - requestAnimationFrame(() => { - const el = inputRef.current; - if (el) { - el.focus(); - const len = el.value.length; - el.setSelectionRange(len, len); - } - }); - } else { - setDraft(toDraftRef.current(value)); - requestAnimationFrame(() => { - const el = inputRef.current; - if (el) { - el.focus(); - const len = el.value.length; - el.setSelectionRange(len, len); - } - }); + } + setDraft(nextDraft); + const el = inputRef.current; + if (el) { + el.value = nextDraft; + el.focus({ preventScroll: true }); + try { + el.setSelectionRange(nextDraft.length, nextDraft.length); + } catch { + // email/number inputs reject setSelectionRange + } + el.scrollLeft = el.scrollWidth; } } wasEditingRef.current = isEditing; diff --git a/apps/client/src/ee/base/styles/grid.module.css b/apps/client/src/ee/base/styles/grid.module.css index 64d986054..569c2fa2d 100644 --- a/apps/client/src/ee/base/styles/grid.module.css +++ b/apps/client/src/ee/base/styles/grid.module.css @@ -184,11 +184,13 @@ light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4)); } -.row:hover .cell { - background-color: light-dark( - var(--mantine-color-gray-0), - var(--mantine-color-dark-7) - ); +@media (hover: hover) { + .row:hover .cell { + background-color: light-dark( + var(--mantine-color-gray-0), + var(--mantine-color-dark-7) + ); + } } .cell { @@ -196,6 +198,7 @@ align-items: center; min-height: 36px; padding: 0 8px; + touch-action: manipulation; font-size: var(--mantine-font-size-sm); color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0)); background-color: light-dark( @@ -227,11 +230,13 @@ ); } -.row:hover .cellPinned { - background-color: light-dark( - var(--mantine-color-gray-0), - var(--mantine-color-dark-7) - ); +@media (hover: hover) { + .row:hover .cellPinned { + background-color: light-dark( + var(--mantine-color-gray-0), + var(--mantine-color-dark-7) + ); + } } .cellEditing { @@ -247,6 +252,14 @@ z-index: 1; } +@media (hover: none) { + .cellFocused { + outline: 2px solid var(--mantine-color-blue-5); + outline-offset: -2px; + z-index: 1; + } +} + .headerCell:focus-visible { outline: 2px solid var(--mantine-color-blue-5); outline-offset: -2px; @@ -470,6 +483,7 @@ cursor: pointer; opacity: 0; pointer-events: none; + touch-action: manipulation; transition: opacity 80ms ease, color 80ms ease; } @@ -486,17 +500,21 @@ } } -.rowExpandButton:hover { - 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)); +@media (hover: hover) { + .rowExpandButton:hover { + 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 { - display: none; -} -.row:hover .rowNumberCheckbox, -.row:hover .rowNumberDragHandle { - display: inline-flex; +@media (hover: hover) { + .row:hover .rowNumberIndex { + display: none; + } + .row:hover .rowNumberCheckbox, + .row:hover .rowNumberDragHandle { + display: inline-flex; + } } .rowSelected .rowNumberIndex { @@ -511,13 +529,23 @@ .bodyGrid:focus .cellFocused .rowNumberCheckbox { display: inline-flex; } +@media (hover: none) { + .cellFocused .rowNumberIndex { + display: none; + } + .cellFocused .rowNumberCheckbox { + display: inline-flex; + } +} .rowSelected .cell { background: light-dark(var(--mantine-color-blue-0), var(--mantine-color-dark-6)); } -.row.rowSelected:hover .cell, -.row.rowSelected:hover .cellPinned { - background-color: light-dark(var(--mantine-color-blue-1), var(--mantine-color-dark-5)); +@media (hover: hover) { + .row.rowSelected:hover .cell, + .row.rowSelected:hover .cellPinned { + background-color: light-dark(var(--mantine-color-blue-1), var(--mantine-color-dark-5)); + } } .rowNumberHeaderInner { diff --git a/apps/server/src/ee b/apps/server/src/ee index 21621440e..2211f14b2 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit 21621440efce47bde317b503af4b230b8865f73b +Subproject commit 2211f14b2c5478899b658c010a97ee64bf12d351