feat(ui): drag-drop parity on iOS + macOS#24
Open
LeslieOA wants to merge 10 commits into
Open
Conversation
Board card drag and list reorder used per-target onPointerEnter / onPointerLeave to detect which column/row the finger was over. Those are hover events; RN doesn't fire them during a touch drag (only mouse). Net effect on iOS: card lifted, ghost followed the finger, but no column ever lit up and the drop silently no-op'd. Replaced per-target enter/leave with rect hit-testing driven by the root container's onPointerMove (which DOES fire on RN with screen- space coords via nativeEvent.pageX/pageY). Each drop zone registers its rect; on every move we walk the registry to find the hit. Same code path now drives column / row hover on web AND native. useDropTargets hook lives in packages/ui/src/internal/ as a .ts + .web.ts pair: - Web: reads rect lazily via getBoundingClientRect() at hit-test time. No caching needed — the call is cheap and always current. - Native: caches screen-space rects via measureInWindow, triggered on each onLayout. RN re-fires onLayout when the layout engine repositions a view, so list-reorder previews don't need manual invalidation — the rects refresh as rows shuffle. Also unified the drop-commit path: root onPointerUp uses the last- hit target instead of per-target onPointerUp. The per-target version worked on web but was fragile on touch when the finger lifted in gap space between targets. Left for follow-up: auto-scroll near edges when dragging in a long list / wide board on touch. Nice-to-have, not parity-blocking. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
RSD's strict prop whitelist rejects \`onLayout\` (it's not in the spec'd "web" event set), so the previous useContainerWidth and the new useDropTargets native variants were both silently failing on macOS / iOS: - Calendar: dayCellWidth stayed at 0 → month grid rendered as a thin compressed strip (visible regression on the macOS demo) - Board drag: rect cache stayed empty → hit-test never matched → drop never committed (visible regression in PR #24) Reset both hooks to attach a ref to the html.div instead. RSD forwards refs through to the underlying RN node, which exposes \`measureInWindow\` for screen-space rects. useContainerWidth: measures on ref-attach + on Dimensions change events (window resize / orientation change). That covers the macOS-desktop and iOS rotation cases without needing a layout- event stream. useDropTargets: explicit \`remeasure()\` function. BoardView calls it on drag-start (columns stay put for the rest of the drag); ListView calls it from a useEffect tied to previewOrder changes (rows shuffle mid-drag). Web variant gets a no-op remeasure for shape parity — its getBoundingClientRect reads are lazy and always current. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
RSD whitelists pointer events on its native side, but RN-macOS doesn't
emit them for mouse input — so onPointerDown / onPointerMove /
onPointerUp on the desktop app silently never fired. Same likely on
iOS for touch (W3C pointer events are still gated in some RN
versions).
Mouse events fire on web + macOS native (NSEvent-driven). Touch
events fire on web touch + iOS / Android native. Listening for both
covers every platform we care about without depending on the W3C
pointer event spec being implemented.
Changes:
- useDragPointer: dragProps now exposes onMouseMove + onTouchMove
- BoardView + ListView: per-card onMouseDown + onTouchStart for drag
start; root onMouseUp + onTouchEnd for commit / cancel
- New pointerCoords() helper extracts {x, y} across all four event
shapes (web mouse, web touch, RN mouse, RN touch) — handles
e.clientX, e.touches[0].clientX, e.nativeEvent.pageX,
e.nativeEvent.touches[0].pageX
On web, both mouse and touch handlers fire — they target the same
state with idempotent updates, so React's bail-on-equal short-
circuits the redundant set.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
RN-macOS's Modal implementation crashes at construction
("Exception in HostFunction" inside ReactFabric createNode), so any
surface using the cross-platform Portal — body editor, schema field
popover, drag ghost — instantly blew up on macOS. iOS / Android were
unaffected, but having one mechanism that works everywhere beats
case-splitting macOS.
Replaced Portal's native implementation with a context-based portal
host:
- PortalHost provider mounted near the app root in apps/desktop and
apps/mobile. Establishes a containing block (position: relative)
so portaled children with absolute positioning size against the
full window.
- Each Portal consumer registers its children with the host via
useId + useEffect. The host renders them after the normal tree
(so they paint on top) using Fragments (so they don't introduce
event-catching wrappers).
- Falls back to inline rendering when no host is mounted — keeps
consumers from silently disappearing if an app forgets the host.
Web variant (Portal.web.tsx) still uses createPortal directly — real
DOM portals work and we don't need the host indirection on web.
Re-exported PortalHost from @workspace.sh/table-ui so apps can mount
it without reaching into internals.
Closes #25. Also unblocks PR #24 — drag ghost no longer crashes on
macOS.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The Provider's \`value={{ add, remove }}\` was an inline object literal,
new identity on every host render. React's context optimization
re-renders every consumer when the value's identity changes — so
every Portal in the tree re-rendered on every host state update,
which re-fired their effect, which called add(), which set state,
which re-rendered the host... "Maximum update depth exceeded."
Visible during board / list drag — DragGhost's pointer-pos updates
made the Portal re-emit children rapidly enough to lock the app.
useMemo with stable add/remove deps gives a stable api object and
breaks the loop. Drag now commits cleanly.
The pre-existing "display:flex required on parent" warning is
unrelated and predates this PR — separate.
RSD's strictAttributeSet whitelists \`onMouseMove\` (passes the validation check) but the native pass-through in \`useNativeProps\` only handles onMouseDown/Up/Enter/Leave/Out/Over — onMouseMove silently never reaches the underlying View. So root-level move tracking didn't fire on macOS during a drag; the card lifted on mouseDown, then nothing until the user clicked elsewhere and mouseUp finally fired with hoveredColumn never set. (List "worked" only in the visual sense — drag-start set previewOrder to the current row order, mouseUp committed it unchanged. Same root cause; the user just didn't notice the rows weren't actually moving.) Restored per-target onMouseEnter handlers — those DO pass through on native and fire on macOS mouse hover. Touch / iOS still uses the root onTouchMove + hitTest path. Web fires both; the state updates are idempotent so the duplicate is harmless. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
User repro: drag on board lifts the card, mouseEnter on destination column DOES fire (state preserved across drag), but mouseUp on release doesn't — a SUBSEQUENT click anywhere is what finally fires the commit path. RN-macOS appears to consume mouseUp at the end of a press-drag (treated as a drag gesture at the NSEvent layer) while fresh clicks dispatch normally. Now that the unrelated Modal crash is gone we can actually test pointer events on RN-macOS again — RSD does pass onPointer* through on the native path. Bound: - onPointerUp + onPointerCancel at the board / list roots - onPointerEnter on each column / row (alongside onMouseEnter) - onPointerMove in useDragPointer's dragProps All handlers do idempotent state updates so double-firing on web (both mouse + pointer fire for the same gesture) is a no-op. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
User's repro: after dragging a board card and releasing, hovering elsewhere highlights destination columns (state preserved, mouseEnter fires). A subsequent click commits. So the press-down + mid-drag hover events work — only release detection is missing. Hypothesis: RN-macOS dispatches mouseUp / pointerUp to the View that received the original press-down (mouse capture), and synthetic-event bubbling up through deep nesting (root → column → card-wrapper) to the root handler doesn't work. List rows ARE direct root children so the bubble succeeds there; cards aren't, so the release dies on the press target. Attached onMouseUp + onPointerUp + onPointerCancel directly to each card wrapper, parallel to the existing root-level handlers. Either catches the release; endDrag is idempotent. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Your repro nailed the root cause: RN-macOS doesn't dispatch any
synthetic events during a press-drag — not mouseMove, not mouseEnter,
not mouseUp. Only mouseDown at start, then events resume after
release (mouseEnter starts firing once button is up). That's why
the column highlights worked AFTER releasing but never during; the
"click to commit" pattern was the only way through.
Synthetic React events on RN-macOS are dead during the press, so
no amount of binding more event types helps. The fix is to bypass
React's event tree entirely for press lifecycle and use RN's native
responder system via Pressable's onPressIn / onPressOut.
DragPressable wrapper:
- Native (.ts): re-exports RN's Pressable.
- Web (.web.ts): html.div with mouseDown/Up + touchStart/End,
normalising event shape to {nativeEvent: {pageX, pageY}} so the
consumer doesn't branch on platform.
BoardView card flow:
- onPressIn → setDraggedRowId + setHoveredColumn(source) + remeasure
- onPressOut → hit-test release coords against column rects, commit
if target ≠ source, reset state
The hit-test-on-release path bypasses hoveredColumn-state for the
commit decision, because on macOS that state can't update during
the press. The card visually stays at source until release on
macOS, then jumps to the column the cursor was over. On web the
column's onMouseEnter still drives a live preview during drag —
unchanged.
ListView left as-is for now; user reports it works. If that turns
out to be a visual lie (preview rendered but no actual commit on
release), same Pressable refactor applies.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds console.error calls at each step of the drag-press cycle: onPressIn, onPressOut (with coords), hitTest result, commit decision, plus rect cache population in useDropTargets. Output goes through to the TableDesktop debug overlay alongside RSD warnings — no debugger attach needed. Will revert once we know which step fails.
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.
Brings board card drag and list reorder up to web parity on iOS and macOS.
Problem
Per-target `onPointerEnter` / `onPointerLeave` doesn't fire on RN during a touch drag — those are hover events, mouse-only. On iOS today: card lifts, ghost follows finger, but no column ever lights up and the drop silently no-op's. Same story for list reorder.
Fix
Replaced per-target enter/leave with rect hit-testing driven by the root container's `onPointerMove` (which DOES fire on RN with screen-space coords via `nativeEvent.pageX/pageY`). Same code path now drives hover detection on web and native.
New `useDropTargets` hook in `packages/ui/src/internal/` (`.ts` + `.web.ts` pair):
Also unified the drop-commit path: root `onPointerUp` uses the last-hit target instead of per-target handlers. Fixes the edge case where the finger lifts in gap space between columns/rows.
Out of scope
Auto-scroll near edges when dragging in long lists / wide boards. Nice-to-have follow-up, not parity-blocking.
Test plan
🤖 Generated with Claude Code