Skip to content

fix: lock each swipe to one axis in the scrolling day and week views - #261

Merged
Moltenship merged 11 commits into
mainfrom
fix/scrolling-axis-lock
Oct 15, 2026
Merged

Moltenship merged 11 commits into
mainfrom
fix/scrolling-axis-lock

Conversation

@Moltenship

@Moltenship Moltenship commented Oct 15, 2026 •

Copy link
Copy Markdown
Owner

Outcome

Closes #260

Parent epic: #217 (found while checking #220 on a real iPhone)

On iPhone Safari, a swipe in the scrolling day, 3-day and week views now moves one axis, chosen by how it starts: a swipe down never pages the days, and a swipe sideways never moves the hours. The hours scroll in the viewport, and the days scroll sideways in an element inside it, so iOS locks each gesture to one of the two nested scrollers. Before, both axes scrolled in one element with snap-x snap-mandatory, which iOS doesn't direction-lock, so a mostly vertical swipe's horizontal velocity flipped the page.

Contract and scope

  • Public API or behavior changed:
    • @moltenship/calendar-dnd (and @moltenship/calendar): new pointer option sidewaysScrollContainer, a ref to an element inside scrollContainer that scrolls sideways in its place at the left and right edges. Defaults to scrollContainer, so other views are unchanged. API report and the docs' options table updated.
    • Registry scrolling-time-grid-view: scrolling-time-grid-viewport now scrolls only the hours, and a new scrolling-time-grid-days element scrolls the days (with the snap points). The header and all-day row are sticky above the days, and their days move by a transform on a ScrollTimeline of the days' scroller, inside an overflow: clip parent. The browser runs that animation with the scroll itself, off the main thread where it can. Without ScrollTimeline, the header follows scroll events instead. The days element has no scrollbar, and the viewport has overscroll-x-contain.
    • All-day titles stay pinned to the start of the days on screen while their bars' starts are scrolled past, as position: sticky did. sticky doesn't work under the transform, so each title has its own animation on the same scroll timeline. Drag copies show the title at their start.
    • Registry time-grid-view: TimeGridAllDayCells drops stickyLabels, which only this view used.
    • Registry scrolling-axis: a scroll it starts jumps instead of animating when focus is anywhere in the view's grid, not only in the scroller (focus can sit in the header now). Found by the WebKit run.
  • Packages/apps/registry items affected: calendar-dnd; registry scrolling-time-grid-view, calendar (scrolling-axis.tsx); architecture.md §13; docs API table.
  • Explicitly out of scope: the resource columns and timeline, which scroll freely on both axes without paging; the app's copy of the view.
  • ADR added or updated, if required: none; architecture.md §13 records the layout.

Behaviour changes to note:

  • A sideways swipe or trackpad scroll over the header or all-day row no longer pages; one over the hours does. A vertical swipe over the header still scrolls the hours.
  • The days' horizontal scrollbar is hidden. Before, it ran along the bottom across a canvas thousands of days wide. Shift+wheel, trackpads, swipes, the toolbar and the keyboard still page.
  • The first version moved the header through scroll events. On the owner's iPhone it visibly trailed, froze and caught up late: Safari keeps page rendering near 60 Hz by default while it scrolls at 120. The scroll-timeline transform replaces it. Safari before 26 has no ScrollTimeline and keeps the scroll-event behaviour.
  • On the owner's recording, fast backward flings showed unpainted (black) days for about half a second, cut at a line in the middle of a day in both the header and the body. That points to WebKit's tile coverage falling behind while the main thread renders the range update, not to unrendered days. Compare with the same fling on main's demo.

Acceptance criteria

  • The linked issue's acceptance criteria are checked and satisfied, except the real-iPhone check, which is left to the owner (below).
  • The PR closes one primary implementation task.
  • Native blocker relationships still match the real merge order.
  • Public source is imported through package entry points; there are no deep imports.

Evidence

  • vp check
  • Affected tests: calendar-dnd unit test for sidewaysScrollContainer; new tools/a11y/tests/scrolling-axis-lock.a11y.tsx (18 tests); existing scrolling, touch, RTL and phone suites pass, with their helpers pointed at the new scrollers.
  • Affected production builds (vp run ready)
  • SSR/hydration evidence: the server markup is unchanged in kind. Both scrollers start at 0 with the range at the canvas start, as before; ready runs the SSR fixture.
  • Performance evidence: no new render work. The scroll listener sets one scrollLeft.
  • Browser/visual evidence: screenshots below, taken in WebKit. The layout is pixel-identical at rest.
  • Accessibility/keyboard evidence: axe in both themes and the keyboard paging test pass in Chromium and WebKit. The grid's rows and cells are unchanged.
  • Registry/consumer-install evidence: registry:smoke in ready; no new files or dependencies.

What the tests cover. The diagonal swipe doesn't reproduce in an automated browser: Chromium rails touch scrolls in the old layout too (a 60/300 px swipe kept the day), and Playwright's WebKit can't drag a finger. So the tests check the invariants instead:

  • Layout. In the day, 3-day and week views, LTR and RTL, only two elements scroll: the viewport vertically only, and the days sideways only, each with the other axis's overflow hidden. This holds in WebKit too, and it's the property iOS needs to lock a gesture.
  • Header. The day headers stay within 1 px of their columns on every frame of a smooth Next, and while a scroll that stops between pages settles, in LTR and RTL.
  • Touch (CDP, Chromium). Vertical swipes with up to 60 px of drift scroll the hours and keep the page; sideways swipes with up to 60 px of drift page the days and keep the hours.
  • Wheel (CDP, Chromium). A wheel down over the days scrolls the hours; a sideways flick pages the days.
  • Drag. A drag held at the bottom edge scrolls the hours and keeps the days; the existing test still carries a drag sideways past the days rendered at first.
  • Opening position. The hours open with the current time a third of the way down.

vp run ready: every PR check passed (local, on 49ca150; vp run browsers in WebKit passes too). The header tests run twice, once with scroll timelines and once without (stubbed), in LTR and RTL, in Chromium and WebKit.

Before After
3-day view, iPhone size, light (WebKit) 3-day before 3-day after
Week view, desktop, dark (WebKit) Week before Week after

Architecture invariants

  • External event data remains authoritative.
  • Time intervals remain explicitly typed and half-open.
  • No host timezone/current time is read implicitly during render.
  • Core remains React, DOM, Jotai, dnd-kit, Tailwind, and application independent.
  • The change adds no total-event/resource scan inside render, scroll, or pointer frames.
  • Pointer capabilities retain keyboard and explicit-action equivalents.
  • Controlled state is not mirrored through an effect.
  • Custom views need no internal imports.
  • SSR output is deterministic or the client enhancement is explicit.
  • No god file or structural boolean-prop matrix was introduced.

Risk and rollout

  • Main risk: the lock rests on iOS handing a gesture to the nested scroller that matches its first direction, which can't be checked without a device. To check on an iPhone (demo preview of this branch):
    1. Diagonal swipes up and down never change the days.
    2. Sideways swipes page with momentum and never move the hours.
    3. The header keeps up with the days during a fast swipe, with no trailing or freezing. Note the iOS version.
    4. A long-press drag still moves, resizes and creates.
    5. A drag at the side edges pages, and one at the bottom scrolls the hours.
  • Safe rollback/reversal: revert this commit. sidewaysScrollContainer is optional and additive.
  • Follow-up work that is genuinely outside this issue: sideways swipes over the header could page too, if wanted. The resource columns scroll both axes in one element, without snapping.

🤖 Generated with Claude Code

Moltenship and others added 3 commits October 15, 2026 17:30
The scrolling day, 3-day and week views scrolled the hours and the days in
one element on both axes, with a mandatory x snap. Safari on iOS locks no
direction in such a scroller, so a swipe down could page the days and a
swipe sideways moved the hours. The hours now scroll in the viewport and
the days sideways in an element inside it; the header follows the days'
scrollLeft. calendar-dnd gains sidewaysScrollContainer, so a drag at the
side edges scrolls the days while the top and bottom scroll the hours.

Closes #260

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The header followed the days through scroll events, which Safari runs at
no more than 60 Hz while it scrolls at 120, so it trailed and froze during
a swipe. Its days now move by a transform on a ScrollTimeline of the days'
scroller, with scroll events as the fallback. All-day titles start on the
first day shown instead of sticking.

Also from review: contain sideways overscroll on the viewport, test focus
moving into the header and drops through sidewaysScrollContainer, name the
test helper's scroller, and list the option in architecture.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Run the header tests with and without scroll timelines, place a bar's
title a whole number of days on, and rewrap the README.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@Moltenship

Copy link
Copy Markdown
Owner Author

Local gate: vp run ready passes on 0f23dac.

Review (Claude Opus 5.5, xhigh), two rounds. No open code blockers.

Round 1 (5ca2c7b):

  • Should-fix: a sideways trackpad swipe over the header could navigate the browser back. Fixed with overscroll-x-contain on the viewport, and a test.
  • Should-fix: the header lagged after drag auto-scroll and resizes. The scroll-timeline header covers this in browsers that have ScrollTimeline. The fallback keeps the lag.
  • Nits: fixed. There's now a test for focus moving into the header, a drop-path unit test for sidewaysScrollContainer, a clearer test-helper name, and architecture.md lists the option.

Between rounds: on the owner's iPhone, the header visibly trailed and froze during swipes. Scroll events run at about 60 Hz while Safari scrolls at 120. The header now moves by a ScrollTimeline transform, and the all-day titles no longer stick.

Round 2 (a83ad6f):

  • Should-fix: the fallback path was untested. Fixed in 0f23dac. The header tests now also run with ScrollTimeline stubbed out.
  • Nit: the title offset added 1 px per day. Fixed.
  • Nit: a long README line. Rewrapped.
  • Nit: titles jump mid-swipe. Accepted and documented.
  • Held blocker (verification): nobody has confirmed on an iPhone that Safari 26 runs the timeline with the scroll. Main-thread tests can't show it, so the owner is checking on a device. Safari before 26 falls back to scroll events.

Moltenship and others added 5 commits October 15, 2026 18:16
Titles placed on the first day shown scrolled away with their bars mid
swipe and came back only once the range moved. Each title now has an
animation on the days' scroll timeline that holds it at the start of the
days on screen, as position: sticky did, with scroll events as the
fallback. TimeGridAllDayCells drops stickyLabels, which nothing else used.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Measure every title before writing, from layout offsets its own shift
doesn't move, and keep a pin's animation while its title and bar stay put,
so a render mid-swipe doesn't restart it. Share one scroll timeline per
layout. A dragged bar's copy shows its title at its start.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The drag-copy test was lost in an earlier edit; it now drags a pinned
all-day bar into a timed column and checks the copy's title. Kept pins
also get their transform rewritten for the scroll-event fallback.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@Moltenship

Copy link
Copy Markdown
Owner Author

Local gate: vp run ready passes on 33538dc.

Owner's iPhone recording after a83ad6f. The header's days tracked the columns, so the scroll-timeline transform works on the device. Two problems showed, broken down frame by frame:

  • All-day titles scrolled away mid-swipe. My scrolledDayWidth margin replaced sticky titles. A title left with its bar and came back about 0.5 s after rest. Fixed in 1549d12: each title is pinned on the same scroll timeline, as position: sticky did.
  • Black days during a fast backward fling. The edge falls in the middle of a day, with no grid lines, and cuts events at the same x in both the header and the body. That reads as WebKit tile coverage falling behind, not unrendered days. The reviewer agrees. Range updates are now cheaper, which should help; compare with main on a device.

Review round 3 (1549d12). No blockers.

  • Should-fix: pins restarted on every render. A pin now keeps its animation while its title and bar stay put. Titles are measured from layout offsets, all reads before writes, with one timeline per layout.
  • Should-fix: drag copies inherited the title's shift. Fixed with ghostClassName.
  • Nit: batch the reads before the writes. Done.
  • Nit: long titles don't pin. Same as main; left as is.

Review round 4 (0bc8f14). No blockers.

  • Should-fix: the drag-copy test was missing. An earlier edit had lost it. 33538dc adds it back: it drags a pinned bar into a timed column, and it fails without the fix.
  • Nit: rewrite the transform of pins that are kept. Done.
  • Flaky touch-paging test under gate load. The swipe steps now each cross the touch slop. It passes at --mode ci-speed.

… pinned

WebKit sends the page's mouse a move at its spot whenever layout changes
under it, which the drag took for its own pointer when both had id 1. The
drag-copy test now uses another id and waits for the copy, and first
checks the title it drags is pinned.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@Moltenship

Copy link
Copy Markdown
Owner Author

Local gate: vp run ready passes on 49ca150, and vp run browsers (WebKit) passes too: 269 passed, 41 skipped.

Review round 5 (33538dc). No blockers.

  • Should-fix: the drag-copy test failed in WebKit RTL. This was a test artifact, not a product bug. During the drag, WebKit sends the page's resting mouse (pointerId 1) a move when layout changes under it, and the test's synthetic pointer also used id 1. The test now uses its own pointer id, waits for the copy, and first checks that the title it drags is pinned. It passed 3/3 runs in WebKit.
  • A stray day line in each column in WebKit RTL. main has it too (same screenshot), so I filed it as Scrolling time grid draws a stray day line in each column in WebKit RTL #262.

@Moltenship

Copy link
Copy Markdown
Owner Author

Review round 6 (49ca150): no blockers or should-fix findings. The last nit is done in a5450f9: the drag-copy test now asserts that the title has actually moved along its bar (m41 ≠ 0). vp run ready passes on a5450f9. The reviewer's only remaining condition is the owner's iPhone check: pinned all-day titles must not jump when the range updates mid-swipe.

CI's Linux runners sometimes deliver the scroll event a frame after the
days move, so the fallback header trails by that frame's distance. The
test allows that in the fallback, keeps the timeline at a pixel, and still
checks both at rest.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@Moltenship
Moltenship merged commit 09f649c into main Oct 15, 2026
4 checks passed
@Moltenship
Moltenship deleted the fix/scrolling-axis-lock branch October 15, 2026 16:09
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.

Scrolling day and 3-day views move both axes in one swipe on iPhone

1 participant