Skip to content

feat(react-core): touch gestures for mobile - #152

Merged
righ merged 8 commits into
masterfrom
righ/touch-support
Oct 13, 2026
Merged

righ merged 8 commits into
masterfrom
righ/touch-support

Conversation

@righ

@righ righ commented Oct 10, 2026 •

Copy link
Copy Markdown
Member

Why

On phones and tablets most grid interactions did nothing. I checked them on an iPhone-sized viewport with touch emulation:

Gesture Before
Tap to select / double tap to edit worked
Drag to select a range didn't work: stayed on one cell
Drag the autofill handle (7×7px) didn't work
Drag a column/row resizer (3px) didn't work
Long press for the context menu didn't work: iOS never fires contextmenu
Swipe to scroll scrolled, but also moved the selection to the touched cell
Focusing the editor 12px inputs, so iOS zooms the page in; every tap focused the hidden textarea, so the soft keyboard likely popped up on plain selection

Cause: Cell / HeaderCell* listened for touchstart but explicitly ignored touch in their drag handlers. Autofill and resize were mouse-only.

What

A new useTouchGestures hook, attached in Tabular, decides what a touch means. It reuses the existing mouse path instead of duplicating it:

  • tap → select. The tap is replayed as mousedown/mouseup, so everything the mouse path does still works, including inserting a cell reference when you tap a cell while typing a formula. The browser's own emulated mouse events are not relied on, because iOS only fires them for elements it considers clickable.
  • double tap → edit, with the soft keyboard
  • long press → context menu on a cell (the pressed cell becomes the target unless it's already in the selection); column / row menu on a header
  • drag from the cursor cell or a selected header → extend the selection, auto-scrolling near the edges
  • drag the autofill handle / a resizer → autofill / resize (these reuse StoreObserver's mouseup commit and the Resizer overlay)
  • anything else → native scroll, with the selection left alone; a scroll whose first movement is clearly vertical or horizontal is locked to that axis, like the Sheets / Excel mobile apps. iOS Safari doesn't do this itself, so a vertical swipe used to drift sideways. The lock switches the other axis off with overflow until scrolling settles, because writing scrollLeft to undo the drift cancels iOS's momentum scroll.

Other changes:

  • The cell editor keeps inputmode="none" until editing starts, so selecting a cell doesn't open the keyboard. A double tap lifts it and refocuses inside the gesture so the keyboard opens.
  • touch-action: manipulation on the root, so a double tap edits instead of zooming. Pinch zoom still works.
  • @media (pointer: coarse):
    • inputs are 16px (no iOS focus zoom)
    • the autofill/resizer hit areas grow without changing how they look
    • keyboard shortcut hints are hidden
  • The header ⋮ menu buttons keep their current size; a long press on the header opens them instead.
  • The old touchstart branches in Cell / HeaderCellTop / HeaderCellLeft are removed. They caused the selection jump on scroll.

Long-press menus, after a first round on a real phone:

  • The held cell / header tints up over the long-press time, so the menu no longer appears out of nowhere. Android also gets a short vibration when it opens.
  • The context menu opens below the pressed cell, or above it when it doesn't fit, instead of with its first item under the finger. If it fits on neither side, it scrolls on the roomier one.
  • On touch devices menus are opaque (they were 90% until hover, and touch never hovers), with 44px rows and press feedback in place of sticky hover.
  • A swipe on a menu overlay no longer scrolls the page or sheet beneath it, neither on the backdrop nor past a scrolling menu's end.

Menus now follow the sheet's look on every device. They are portaled to <body>, outside .gs-root1, so they used to render in the page's font with fixed light colors, white even in dark mode. The portal now carries the sheet's font-family, data-mode and --gs-* tokens, as computed on the root so app overrides apply, and menu.less reads the tokens instead of fixed greys.

Fixes found along the way:

  • iOS scrolling. preventSafariBounce (pre-existing) cancelled any touchmove toward an edge the grid had already reached. iOS then drops the whole gesture, so at the initial top-left a swipe drifting even slightly right or down didn't scroll at all. Touch scrolling is now left native; the guard stays for wheel / trackpad.
  • ScrollHandle ignores a mouseenter with no button held. Chrome on Linux fires boundary mouse events at the stale cursor position (0,0) when the handles appear mid touch-drag, which yanked the selection to the edge. This only failed on CI.

Desktop mouse behavior is unchanged apart from the menu theming. The full e2e suite passes, 126 tests including the new ones.

Tests

e2e/touch.spec.ts adds 18 tests on a 390×664 viewport with hasTouch/isMobile. Drags and long presses use CDP Input.dispatchTouchEvent; the suite already runs on Chromium only. One group runs with an iOS Safari user agent, so Safari-only code paths such as preventSafariBounce are exercised. The long-press test also checks that the menu doesn't cover the pressed cell.

Not covered / follow-ups

  • Real devices: partly checked. The author tried it on a phone through a tunnel; the scrolling and menu issues above came from that. Still worth checking on iOS Safari and Android Chrome:
    • the keyboard opens on double tap
    • no keyboard appears on a plain tap
    • the long-press callout is suppressed
  • autoExpand ghost rows/cols don't grow during a touch autofill drag. They're driven by mousemove past the content edge.
  • Hover-only UI (the formula error tooltip) is still unreachable by touch.
  • svelte-core has its own implementation and gets only the CSS part.
  • Selection extension starts only from the cursor cell or a selected header. Corner selection handles, as in the Sheets / Excel mobile apps, were considered and left for later.
  • Releasing this is a minor bump (3.6.0): it's a backward-compatible feature.

🤖 Generated with Claude Code

righ and others added 8 commits October 10, 2026 19:12
Drags on touch devices did nothing (range select, autofill, resize), a long
press opened no menu on iOS, and a swipe to scroll moved the selection.

A new useTouchGestures hook (attached in Tabular) decides what a touch means
and reuses the existing mouse path rather than duplicating it:

- tap: select (replayed as mousedown/mouseup, so formula ref insertion etc. work)
- double tap: edit, with the soft keyboard
- long press: context menu on cells, column/row menu on headers
- drag from the cursor cell / a selected header: extend the selection,
  auto-scrolling near the edges
- drag the autofill handle / a header resizer: autofill / resize
- anything else: native scroll, selection untouched

The cell editor keeps inputmode="none" until editing starts, so selecting a
cell no longer pops up the keyboard. On coarse pointers, inputs are 16px (no
iOS focus zoom), the autofill/resizer hit areas grow without changing their
look, and keyboard shortcut hints are hidden. The header menu buttons keep
their size; a long press opens them instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Chrome on Linux fires boundary mouse events at the last known cursor
position (0,0) when the scroll handles appear during a touch drag. The
handle took that as the pointer entering it mid-drag and pulled the
selection / autofill to its edge, failing the touch e2e specs on CI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On iOS (every iOS browser matches the Safari UA check) the bounce guard
cancelled any touchmove heading toward an edge the grid had already
reached. iOS then drops the whole gesture, so at the initial top-left a
swipe drifting even slightly right or down didn't scroll at all. Keep the
guard for wheel/trackpad only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Tint the held cell / header over the long-press time so the menu
  doesn't appear out of nowhere; vibrate briefly on Android when it opens.
- Open the menu below the pressed cell, or above it when it doesn't fit
  (scrolling on the roomier side if neither does) instead of with its
  first item under the finger.
- On coarse pointers: opaque menus (they were 90% until hover), 44px
  rows, press feedback instead of sticky hover, and a short entrance.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Menus are portaled to <body>, outside .gs-root1, so they rendered in the
page's font with hard-coded light colors (white even in dark mode). The
menu portal now carries the sheet's font-family, data-mode and --gs-*
tokens (as computed on the root, so app overrides apply too), and the
menu styles read the tokens instead of fixed greys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A swipe on a menu overlay scrolled the page beneath it (and with it the
sheet), both on the backdrop and once a scrolling menu hit its end. Menu
overlays now block touch panning except inside a scrollable part of the
menu that can still move that way (touch-action / overscroll-behavior,
plus a touchmove guard for iOS versions that don't honor them).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
max-height applied to the content box, so a menu limited to the room
below/above the pressed cell came out ~10px taller; the edge clamp then
pushed it back over the cell (failing the touch e2e on CI).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
iOS Safari scrolls overflow containers freely in both axes, so a vertical
swipe drifted sideways (Chromium already rails touch scrolls). A native
scroll whose first movement is clearly along one axis is now locked to it
by switching the other axis off with overflow, restored once scrolling has
settled. Writing scrollLeft/scrollTop to undo the drift instead would
cancel iOS's momentum scroll. Diagonal swipes still scroll freely.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@righ
righ force-pushed the righ/touch-support branch from 57ca62f to d04ac0a Compare October 13, 2026 14:52
@righ
righ marked this pull request as ready for review October 13, 2026 15:26
@righ
righ merged commit 2769c92 into master Oct 13, 2026
4 checks passed
@righ
righ deleted the righ/touch-support branch October 13, 2026 15:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant