Skip to content

feat(ui): drag-drop parity on iOS + macOS#24

Open
LeslieOA wants to merge 10 commits into
developfrom
feat/native-drag-parity
Open

feat(ui): drag-drop parity on iOS + macOS#24
LeslieOA wants to merge 10 commits into
developfrom
feat/native-drag-parity

Conversation

@LeslieOA

Copy link
Copy Markdown
Member

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):

  • Web: `getBoundingClientRect()` on every hit-test — cheap, always current
  • Native: caches screen-space rects via `measureInWindow`, refreshed by `onLayout`. List reorder previews don't need manual invalidation since RN re-fires `onLayout` after each shuffle.

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

  • Web: drag a card between board columns — still works
  • Web: reorder list rows — still works
  • iOS (apps/mobile): drag a card between board columns — column highlights, drop commits
  • iOS: reorder list rows — preview shuffles, drop commits
  • macOS (apps/desktop): mouse-drag a card between columns — same
  • macOS: mouse-drag list rows — same
  • `npm run typecheck` — clean
  • `npm run core:test` — 77 passing
  • `npm run web:build` — clean

🤖 Generated with Claude Code

LeslieOA and others added 3 commits May 22, 2026 18:24
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>
LeslieOA and others added 7 commits May 22, 2026 18:54
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.
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