Repository navigation
feat(react-core): touch gestures for mobile - #152
Merged
Merged
Conversation
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
force-pushed
the
righ/touch-support
branch
from
October 13, 2026 14:52
57ca62f to
d04ac0a
Compare
righ
marked this pull request as ready for review
October 13, 2026 15:26
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
On phones and tablets most grid interactions did nothing. I checked them on an iPhone-sized viewport with touch emulation:
contextmenuCause:
Cell/HeaderCell*listened fortouchstartbut explicitly ignored touch in their drag handlers. Autofill and resize were mouse-only.What
A new
useTouchGestureshook, attached inTabular, decides what a touch means. It reuses the existing mouse path instead of duplicating it: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.StoreObserver's mouseup commit and theResizeroverlay)overflowuntil scrolling settles, because writingscrollLeftto undo the drift cancels iOS's momentum scroll.Other changes:
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: manipulationon the root, so a double tap edits instead of zooming. Pinch zoom still works.@media (pointer: coarse):touchstartbranches inCell/HeaderCellTop/HeaderCellLeftare removed. They caused the selection jump on scroll.Long-press menus, after a first round on a real phone:
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'sfont-family,data-modeand--gs-*tokens, as computed on the root so app overrides apply, andmenu.lessreads the tokens instead of fixed greys.Fixes found along the way:
preventSafariBounce(pre-existing) cancelled anytouchmovetoward 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.ScrollHandleignores amouseenterwith 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.tsadds 18 tests on a 390×664 viewport withhasTouch/isMobile. Drags and long presses use CDPInput.dispatchTouchEvent; the suite already runs on Chromium only. One group runs with an iOS Safari user agent, so Safari-only code paths such aspreventSafariBounceare exercised. The long-press test also checks that the menu doesn't cover the pressed cell.Not covered / follow-ups
autoExpandghost rows/cols don't grow during a touch autofill drag. They're driven bymousemovepast the content edge.svelte-corehas its own implementation and gets only the CSS part.🤖 Generated with Claude Code