Skip to content

✨ feat: long press for row menus on mobile, plus "Move to…" - #36

Merged
martinezharo merged 6 commits into
mainfrom
t3code/mobile-snippet-long-press
Sep 30, 2026
Merged

martinezharo merged 6 commits into
mainfrom
t3code/mobile-snippet-long-press

Conversation

@martinezharo

Copy link
Copy Markdown
Owner

Summary

  • Long press opens a row's menu on the mobile home. Holding a snippet card or folder header for ~450 ms opens its actions menu at the finger. A scroll or tab swipe cancels it; a normal tap still opens the snippet.
  • The ⋯ button is only shown for a fine pointer (pointer-coarse:sr-only). A narrow desktop window keeps it, and right-click works there too. On touch it stays available to screen readers, which can't long-press.
  • The copy button loses its box and becomes the card's only visible control on touch.
  • New "Move to…" menu entry (shared menu, so the desktop tree gets it too, including multi-selection). It opens a folder picker dialog; picking a folder moves straight away, and folders an item can't go into are disabled. On touch there is no drag & drop, so moving used to mean cut and paste.
  • Refactor: the folder tree from FolderSelect is now a shared FolderTreeList, used by both the dropdown and the dialog. Its rows are taller below lg.

How the long press works

useLongPress times the hold itself, because iOS Safari never fires contextmenu. A native Android contextmenu that beats the timer fires it early instead, so the menu opens only once. After a touch press it swallows the click and the emulated mouse events, so the menu isn't closed by its own backdrop when the finger lifts.

Testing

  • New unit tests for useLongPress (timing, drift/cancel, native contextmenu race, right-click, disabled). Lint, tsc and the full suite pass.
  • Checked in headless Chromium with touch emulation (390×844): the menu opens and stays open on release, moving to Root updates the card, a drag doesn't open the menu, and a tap still opens the snippet. With a mouse in a 700 px window, the ⋯ and right-click both open the menu.
  • Not tested on a real iOS or Android device yet.

Screenshots

Touch home · long-press menu · Move to dialog · narrow desktop window with ⋯

touch home
menu
move dialog
desktop narrow

The "Move to…" dialog needs the same pickable folder tree the FolderSelect
dropdown shows, so the tree helpers and rows move to a shared
FolderTreeList. It starts with the path to the current folder expanded,
replacing the dropdown's expand-on-open effect, and grows its rows below
`lg` so they can be picked with a finger.

The dropdown now repositions through a ResizeObserver, since it no longer
knows when the list grows.
Adds "Move to…" to the shared row menu, opening a folder picker dialog.
On touch there is no drag & drop, so moving used to mean cut and paste;
on desktop it also works on a multi-selection. Picking a folder moves
straight away; folders an item can't go into are disabled.
Holding a snippet card or folder header in the mobile home opens its
menu, so the ⋯ button is now only shown for a fine pointer (a narrow
desktop window), where right-click also works. It stays available to
screen readers. The copy button loses its box and is the card's only
visible control on touch.

useLongPress times the hold itself (iOS never fires contextmenu), lets a
native contextmenu fire it early on Android, and swallows the click and
emulated mouse events after a press so the menu isn't closed on release.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
klipcode d1c6ae3 Commit Preview URL

Branch Preview URL
Sep 30 2026, 11:03 AM

@oli-review oli-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review

Adds touch-first row actions to the mobile home: a new useLongPress hook opens a card/folder menu after a ~450ms hold (or right-click on a mouse), the ⋯ button is hidden behind pointer-coarse, and a shared "Move to…" dialog lets one or many items be relocated. It also refactors the FolderSelect tree into a reusable FolderTreeList used by both the dropdown and the dialog, and wires onMove through WorkspaceTree/MobileHome to a new MoveDialog.

File Note
src/hooks/useLongPress.ts ✅ Race between own timer and native contextmenu handled correctly via fired/pending; unmount clears the timer.
src/components/MobileHome/FeedCard.tsx ✅ Long-press owns the card; ⋯ kept reachable for a fine pointer/screen readers only.
src/components/MobileHome/FolderGroup.tsx ✅ Header long-press mirror of FeedCard; dropping stopPropagation is safe (no ancestor click handler, ⋯ is a sibling).
src/components/MobileHome/MobileHome.tsx ✅ Menu anchor now takes an explicit point; wiring is complete.
src/components/MoveDialog/MoveDialog.tsx 🟡 Disabled-descendant rule and move submission are unverified by tests.
src/ui/FolderTreeList.tsx ✅ Faithful extraction of the old dropdown tree; mount-on-open preserves the expand-the-current-path behavior.
src/ui/FolderSelect.tsx 🟡 Reposition now driven by a ResizeObserver that jsdom does not implement.
src/components/Aside/useContextMenuGroups.ts ✅ targetItems() unifies batch vs single selection for move and clipboard.
src/components/KlipCodeApp.tsx ✅ MoveDialog state, callback and overlay guard wired correctly.

Risk: low

Notes

Logic looks sound; no functional bug found. Worth adding: a MoveDialog test for the disabled-descendant rule and the root/id mapping, and a useLongPress integration test if feasible, since the 450ms gesture is the riskiest new surface. The ResizeObserver stub above should land before any FolderSelect test. Real iOS/Android behavior (callout suppression, touchend click suppression) remains unverified on device — worth a manual pass before release.


Automated review — opencode-go/deepseek-v4.1-flash · 2 comment(s)

Comment on lines +90 to +92
isDisabled={(folderId) =>
movingFolderIds.some((id) => isDescendantOrSelf(folders, id, folderId))
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 minor — Cover the disabled-folder and move paths

The rule that a folder and its subtree are unpickable is the non-obvious part of this dialog, and neither it nor the onMove(""→null) mapping is covered by a test. A small MoveDialog test (folder selected disables itself and its descendants; picking root calls onMove(null)) would pin this down.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in the latest push: src/__tests__/moveDialog.test.tsx covers the folder and its subtree being disabled, root → onMove(null), and picking a folder by id.

Comment thread src/ui/FolderSelect.tsx
dropdownRef.current.style.minWidth = `${minW}px`;
}, [open, visible.length]);
position();
const observer = new ResizeObserver(position);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 minor — ResizeObserver missing in jsdom test env

This is now constructed every time the dropdown opens. jsdom does not implement ResizeObserver and there is no stub in the Vitest setup, so any future test that renders FolderSelect and opens it will throw ReferenceError. Add a stub to the shared test setup.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not applying this one for now. Vitest runs in the node environment by default and the component tests opt into jsdom per file, so a global setup stub would need an environment check, all for a test that doesn't exist yet. No current test opens FolderSelect, and every browser we target has ResizeObserver. Whichever test first opens the dropdown can stub it locally (vi.stubGlobal).

@oli-review oli-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review

Adds src/tests/moveDialog.test.tsx, the first tests for the MoveDialog introduced earlier in this PR. They cover the disabled-descendant rule for moving a folder (self and subfolders disabled, current parent and unrelated folders enabled), the root option mapping the empty string to null and closing, and picking a folder by id. A small four-folder tree plus an English dictionary back the three jsdom cases. No production code changes.

File Note
src/__tests__/moveDialog.test.tsx ✅ Correct and well-scoped: assertions match MoveDialog's isDisabled and onSelect/onMove wiring; expand helper matches the tree's chevron/row structure.

Risk: low

Notes

The new tests are sound and validate the previously-flagged disabled-descendant rule and root/id mapping. Untested here: the multi-item path (subtitle via itemCount and a single onMove for a batch), and the no-op same-folder selection. Neither is risky enough to block. Real iOS/Android long-press and click-suppression behavior remains device-unverified.


Automated review — opencode-go/deepseek-v4.1-flash · 0 comment(s) · incremental

On Android the platform's own long press fires `contextmenu` ~500ms in,
just after our 450ms timer has opened the menu. It is hit-tested at the
finger, so it lands on the menu's backdrop, which closes on contextmenu:
the menu vanished as soon as it appeared. iOS's emulated mousedown on
release could do the same.

A touch press now installs a window-level guard that swallows the rest of
that touch's contextmenu/mouse/click events. It lifts on the next
pointerdown or shortly after the finger lifts, so a keyboard or screen
reader click without a pointerdown still gets through; this replaces the
element-level click swallowing, which could eat such a click on the row.

@oli-review oli-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review

The diff replaces the element-level click/touch suppression in useLongPress with a window-level capture guard installed when a touch long press fires. It swallows contextmenu/mousedown/mouseup/click globally so the just-opened menu's portalled backdrop (or the row's click) isn't triggered by the release; the guard lifts on the next pointerdown or shortly after touchend/touchcancel. The removed onTouchEnd/onClickCapture handlers are gone, and tests were added for the backdrop hit-test race and for clicks passing again after the finger lifts.

File Note
src/hooks/useLongPress.ts 🟡 Window guard logic is sound; the pointerdown release is not filtered by isPrimary, so a second finger can drop it early.
src/__tests__/useLongPress.test.tsx ✅ New tests exercise the backdrop contextmenu race and the post-release click path; assertions match the guard.

Risk: low

Notes

Worth adding a test that a second, non-primary pointerdown does not lift the guard before the gesture ends, and one for release via touchcancel (only touchend is covered). Real iOS/Android release-event ordering still can't be verified here, so device testing of the 400 ms linger remains a follow-up.


Automated review — opencode-go/deepseek-v4.1-flash · 1 comment(s) · incremental

Comment thread src/hooks/useLongPress.ts Outdated
for (const type of SWALLOWED) window.addEventListener(type, swallow, true);
// A new gesture always starts with a pointerdown, and nothing this touch
// sends comes after one.
window.addEventListener("pointerdown", release, true);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 minor — Guard release ignores non-primary pointers

Unlike onPointerDown, which bails on !e.isPrimary, this window listener releases the guard on any pointerdown. A second finger touching anywhere during the hold lifts the guard while the first touch is still down, so the original touch's emulated mousedown/contextmenu can then reach the backdrop and close the menu on release. Gate the release on e.isPrimary (and keep the same function reference for the matching removeEventListener).

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, fixed: the release now only fires on a primary pointerdown, through a named listener so the matching remove works. Added tests for a second finger during the press and for release via touchcancel.

@oli-review oli-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review

This incremental diff addresses the prior review's pointerdown concern. The window-level pointerdown listener that lifts the long-press swallow guard now calls a releaseOnNewGesture handler that only releases when e.isPrimary is true, so a second finger joining the same touch no longer clears the guard early; release() removes the new listener reference. Two tests were added covering a non-primary second pointerdown (guard stays) and release after touchcancel.

File Note
src/hooks/useLongPress.ts ✅ isPrimary filtering correctly distinguishes a new gesture from an additional finger; listener add/remove references match.
src/__tests__/useLongPress.test.tsx ✅ New tests meaningfully exercise the non-primary and touchcancel paths; jsdom v30 supports PointerEvent.isPrimary.

Risk: low

Notes

No issues found. Coverage is adequate: the primary-pointerdown release path is exercised indirectly by the existing 'next tap' test, and the two new tests cover the non-primary and touchcancel cases raised previously. Remaining follow-up is unchanged: verify the GUARD_LINGER_MS release ordering on real iOS/Android hardware, which cannot be validated in jsdom.


Automated review — opencode-go/deepseek-v4.1-flash · 0 comment(s) · incremental

@martinezharo
martinezharo merged commit 9903d9f into main Sep 30, 2026
2 checks passed
@martinezharo
martinezharo deleted the t3code/mobile-snippet-long-press branch September 30, 2026 14:35
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