Skip to content

feat(core): a gesture touch carries the modifier keys its press began with - #8797

Closed
vjeux wants to merge 1706 commits into
meta:mainfrom
vjeux:uikit/touch-modifier-keys
Closed

vjeux wants to merge 1706 commits into
meta:mainfrom
vjeux:uikit/touch-modifier-keys

Conversation

@vjeux

@vjeux vjeux commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Depends on #8665. Review only this pull request's change: vjeux/astryx@c6c02fc...5760fdf

Why

In a group of links, a Ctrl-, Cmd- or Shift-click on a member should open that link in a new tab or window, as it does everywhere else in the browser. The gesture system could not tell such a click apart: a gesture touch carried no modifier keys, so a group that tracks a held pointer across its members (#8657) would take the click as its own.

What led to it

The review of #8657, ButtonGroup's press tracking across its buttons, found the gap. The group's half, declining such a click, follows in #8657 on top of this one.

Options and tradeoffs

  • Let the group read the keys from the DOM event: it sees touches, not events, so it would need its own listener beside the gesture host's.
  • Put the whole native event on the touch: every recognizer would get a mutable browser event and the internals that come with it.
  • Copy the four keys onto the touch when its press begins.

Why this choice

The four keys are all a recognizer needs, read once from the pointer or touch event that began the contact; an input that leaves them out reads as none held. UIKit has no counterpart because a phone has no modifier keys, but on the web a mouse and a keyboard do.

How to check it

  1. Attach a tap recognizer whose shouldReceiveTouch logs the touch, then Cmd-click (Ctrl on Windows and Linux) the element. Expected: the logged touch has metaKey: true (or ctrlKey: true) and the other keys false.
  2. Click without modifiers. Expected: all four are false.
  3. With feat(ButtonGroup): a held pointer is tracked across the buttons, and the one lifted on runs #8657 on top, Cmd-click a link member of a button group. Expected: the link opens in a new tab, and the group does not act.

Evidence

  • A unit case fails before the change and passes after: a mouse press with Cmd and Shift held reaches the recognizer's touch with exactly those keys set.
  • The whole Core UI suite and the Chromium and WebKit gesture proofs pass; pnpm check:repo is clean.
  • Vercel's build failed on an earlier head (c793f21); its exact build command passed locally, the deploy logs need team access, and this head's build succeeds.

cixzhang and others added 30 commits October 26, 2026 18:15
spec:AST-094 FR20: a watcher registers only passive listeners, but the
host gave every tracked finger's target a cancelable touchend. The host
now counts, per target, the fingers a participant could claim (a
recognizer that claims touches, or a press consumer): touchend is
cancelable only while that count is above zero and passive otherwise,
swapped like touchmove. The new test (a watcher's touch gets passive
touchmove, touchend and touchcancel; a tap's touchend stays cancelable)
fails on the previous head.
…ond route around ./internal/*

Lab's per-module tsconfig paths (core's motion, gestures and
touchPresentation) and the Sandbox/docsite resolver that served them opened
a route around architecture:react-component-runtime INV13's ./internal/*
subpath exports. Nothing in Lab imports those paths here; they're dropped,
and the import guard now fails on any Lab import core doesn't export and on
any Lab tsconfig alias for core.

Co-authored-by: Christopher Chedeau <vjeux@users.noreply.github.com>
…) into the host line

The watchTarget call keeps this PR's blocking test (a kept-press consumer
under a coarse pointer blocks too) beside meta#8589's claiming flag.
 landed) into the experimental touch layer

# Conflicts:
#	packages/core/src/motion/README.md
… detaches

The deferred unmount demotes the detached node, and demote asks for a fresh
will-change read; the detached node now gets none, so no timer outlives the
element (architecture:react-component-runtime INV5).

Co-authored-by: Christopher Chedeau <vjeux@users.noreply.github.com>
…#8522, her Lab route through core's exports only, and her will-change timer fix) into the presented start
…ine opacity

The case that detaches a node mid-dip and attaches it again checked, 25 ms
in, that the dip had written its low opacity inline. With the presented
start, a curve's inline value is the one on screen as it starts and its end
is written when it retires, so 25 ms into the fade out the node holds the
fade's starting opacity while the model is already partway down. That case
now checks what this guarantees: the call owns the opacity inline and the
dip is under way; its assertions after the second attach are unchanged.

A dip that starts from a dimmed opacity still leaves that low value on the
node while the move waits to land, so a new case detaches such a node
mid-dip and attaches it again: the move has landed and the node is
visible. It fails (0.3 instead of 1) when the pending unmount does not land
the waiting move on the node just detached, so that landing stays
covered.
…via meta#8076) into the host line

useGestureRecognizer keeps both fine-pointer options: this PR's
perEventPointer and meta#8309's actionGroup.
…style holds

Whatever the inline style holds while the curve runs (the end, or, once
the start-value write of meta#8340 is in, the start), iOS WebKit's rect and
hit tests stay where the animation started: the note now says only what
holds either way.
# Conflicts:
#	packages/core/src/gestures/GestureHost.ts
…t stream

Ports cixzhang's meta#8072 action-group press (d2c3c78) onto this pull
request's shared input stream, with no change to what it does. Its
per-press listeners were registered straight on the document and the
window with capture constants this layer no longer has, so the merged host
failed to compile, and its window click listener would have doubled the
input stream's click subscriber (two onClick calls per click once the host
installs, and a listener its guard never removed).

The press now subscribes its pointer's moves and lift and the window's own
blur through subscribeInput for as long as it is held. It subscribes to
click only while the host is not installed, drops that subscription after
the lift's task, and an installing host takes it over, so a click is heard
once. Her two host tests read the press's listeners on the window, where
the stream registers them; a new case counts onClick per click before and
after the host installs mid-press, and fails against each of three mutants
(no takeover, no drop, no subscription).
…uard

At this layer the host has no click listener of its own: the click guard
(utils/clickGuard.ts) hears every click through one listener per window.
cixzhang's meta#8072 action-group press, ported onto the input stream below,
still disarmed the host's swallower and subscribed the host's onClick, so
the merged host failed to compile. The press now disarms the click guard
when it begins and subscribes no click: what it does is unchanged.

The click-count case becomes: a group press adds no click listener of its
own, before or after the host installs mid-press. GestureHost.test.ts
resets the click guard after each case, so a guard one case armed no
longer keeps its pointerdown subscription into the next (it hid the coarse
host's pointerdown listener from a later case).
… that finger until it lifts (spec:AST-094 FR14)

A recognition that claimed its touches and was then cancelled gave them
back to the browser unless another recognition owned them, so a finger
resting there could pinch-zoom the page. A gesture that cancels itself on
a finger's move, a long press dragged past its tolerance after it began,
gave back a finger that was dragging: the host stopped cancelling its moves
and the page scrolled the rest of the drag, a hand change FR14 forbids and
UIKit never makes (its scroll view never takes a touch another recognizer
had). A cancel during a finger's move now keeps that finger claimed until
it lifts; a resting finger is given back as before, and a waiting
recognition that fails still gives its claim back.

The new arena case fails on the previous code: the dragging finger reads
unclaimed right after the cancel.
… scroller under it is at the edge

spec:AST-094 FR19 and DEC-18 (proposed in meta#8628): a scrollEdge pan may
cancel its touch's moves from the first one, only while every scroller
under the finger sits at its edge and the finger moves away from it, and
only the moves the browser would rubber-band; it still begins at FR17's
10 px. The first case reads the first 2 px move cancelled at the edge and
fails on this head; the others keep what must not change: room to scroll
leaves every move to the browser, a first move toward the content too, a
pan without scrollEdge cancels nothing before it begins, and a touch the
browser took for a scroll stays the browser's when the list reaches its
edge.
… cancelled at the edge

spec:AST-094 FR19 and DEC-18 (proposed in meta#8628). The sheet's scroll-edge
pan at the top of its content: every touchmove the page sees is cancelled,
the first one included. WebKit sends that first move at 2 px, before the
sheet's 10 px begin, and it reaches the page uncancelled on this head;
Chromium sends none inside its own 15 px slop, so there it proves only
that nothing scrolls.
…e away from an edge

spec:AST-094 FR19 and DEC-18 (proposed in meta#8628). GestureRecognizer gains
keepsTouch(touchId), false by default and reached by the host through
recognizerInternals: whether it holds that finger before it begins. A
scrollEdge pan does while it is possible, the touch is a finger the browser
has not taken, its travel so far goes the pan's own way, and no scroller
under the finger can scroll for it (the probe its begin already reads), so
only moves the browser would rubber-band are held, never a real scroll.
The host's blocking touchmove check cancels a move a participant holds, as
it does a claimed one; the arena already holds that move when the check
runs, since the host feeds it from its window-capture subscriber first.
The pan still begins at 10 px; a mouse or a pen is never held. The README
says so.
…straight, (7, 7) and (8, 8), and the screen-edge band

The FR18–FR22 verification row's real-touch states, in the gesture browser
spec on a bare page:
- a finger moved 9 px straight or (7, 7) over a scroll view keeps its tap
  and scrolls nothing; one moved 10 px or (8, 8) is a tap or a scroll,
  never both; a tap never cancels a touchmove;
- a screen-edge pan on a strip as wide as its band, landing 40 px from the
  edge, has not claimed at 14 px of travel and claims at 15;
- a touch landing 41 px from the edge never begins the edge pan, and the
  horizontal scroll it starts is not blocked.
The bare fixture exposes the tap and screen-edge pan recognizers.
…edge-zone strip decides at UIKit's 15 px

The host's arena environment had no viewSize, so under the real host a
screen-edge pan never saw its edge zone while a touch was down and decided
at 10 px, while its attach-time touch-action (read through the default
environment) had already claimed the axis (spec:AST-094 FR18).

Co-authored-by: Christopher Chedeau <vjeux@users.noreply.github.com>
…fter layout rounding

Lane 0's real-host browser spec found a strip authored at the 40.2 px edge
region lays out at 40.203 px (layout snaps to 1/64 px), so the strict
comparison read it as off the zone and the pan decided at 10 px instead of
UIKit's 15 (spec:AST-094 FR18). The zone takes half a pixel of tolerance:
the rounded strip is the zone, a view a pixel wider still is not.
Failing-first test at 40.203 px: it claims pan-x and decides at 15.

Co-authored-by: Christopher Chedeau <vjeuxx@gmail.com>
vjeux added 6 commits October 29, 2026 10:17
…default it moves

The {stylexOptions} form of astryxStylex now passes StyleX
treeshakeCompensation: true unless the caller sets it. spec:AST-017 FR3
asks the release note to name the old and new default and the option
that restores the old one; it now does.
…er and for the two stops the input stream fixes

- A still finger with sub-pixel jitter over a scroll view keeps its tap
  and scrolls nothing (spec:AST-094 FR18–FR22).
- A tap is still recognized under an app's window-capture
  stopPropagation, and under a document-capture
  stopImmediatePropagation registered before core's: the recognizers hear
  touches on the window's capture phase.
Chromium and WebKit pass.
…s once

Where nothing that claims is attached, the host's touchend listener is
passive and cannot cancel the engine's own click after a retargeted tap
clicks what is drawn. The shared click guard swallows that late click
whatever is attached; this case pins it, as asked on meta#8293.
meta#8360 now carries the review fixes of the foundation it holds. The two
conflicts are this pull request's own files:
- GestureHost.ts: meta#8360 counts the attachments that ask for an action
  group; here the fine-pointer group listener and the per-event recognizer
  set are gone (every pointer is served, FR43), so the merge keeps the
  count and drops those two.
- GestureHost.test.ts: both sides added the same assertion to the
  second-finger case; the merge keeps this side's comment. meta#8360's
  action-group lifetime case counts a group's own pointerdown under a fine
  pointer, which this pull request replaces with one passive pointerdown
  on any element that uses a gesture (its own idle-listener case), so the
  merge leaves it out.
… attached, in this host's terms

meta#8360's action-group lifetime case pins that an element stays a group
while any attachment asks and stops with the last. Here every element
that uses a gesture is served as a group under a fine pointer, so the
same invariant reads as the element's one passive pointerdown: with two
gestures attached, detaching either keeps it, and detaching the last
removes it. It passes on this head.
meta#8665 now carries meta#8360's new head (75392b2), which carries the
gesture foundation with the harness meta#8045 and meta#8046 landed on main, so
this pull request no longer carries their old copies. The merge is
clean.

@astracat-bot astracat-bot 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.

Approval waits on an engineering owner's decision about one spec sentence, and the branch still needs splitting from the stack it depends on. This head adds to spec:AST-094 FR41 that “a mouse or pen drag that a recognizer claims suppresses text selection for that drag only”, which main does not state and which FR41's unchanged verification row still fails; an engineering owner approves it, with that row amended, by commenting /approve-spec c793f217e0f7eaf457fdd6d6108df8aa38ae4949 on this pull request, or rejects it, and the author may record that decision as an engineering owner. Separately, the branch merges all of #8665 (about 28,000 changed lines) beside the five-file modifier-key change, so please land #8665 first and update this branch from main, which also takes the FR41 sentence out of this diff; the modifier-key change itself reads cleanly.

[Automated review]

Comment on lines +471 to +473
(`spec:AST-017` FR59; DEC-9). A mouse or pen drag that a recognizer claims
suppresses text selection for that drag only; an unclaimed mouse drag keeps
today's behavior.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This sentence is a new requirement for a mouse and a pen, and it waits on an engineering owner's decision. It follows FR41's own “otherwise keeps its released behavior (spec:AST-017 FR59; DEC-9)”, and FR41's verification row further down still fails “a change to native selection elsewhere or to mouse, keyboard, or assistive-technology selection”, so if it is approved, that row needs amending in the same change; spec:AST-017 FR59 also asks for fine-pointer evidence before and after.

vjeux added 9 commits October 29, 2026 12:16
…esture host's subscriptions

The gesture host subscribes no click: the one cancelling click subscriber
is the click guard's, added at a window's first arm and kept. The release
note, the stream's header and the gestures README listed click with the
host's touchend and contextmenu; they now name the guard's own.
meta#8360's input-stream notes no longer list click among the gesture host's
subscriptions. The merge is clean.
… lift and the browser's menu, keeps a kept press's finger, and goes when the page hides

From the review of this pull request, three cases fail on its head:
- an armed press installed the page listeners without the claiming
  ones, so a claimed finger's touchend stayed passive and the browser's
  menu opened during a claimed long press (spec:AST-094 FR21);
- a kept press got its blocking touchmove during an armed press, but its
  moves were cancelled only where the primary pointer is coarse, so a
  held stepper on a touchscreen laptop lost its finger (FR11, DEC-21);
- a page hidden mid-press cancelled the press but kept the page's
  listeners until the next lift anywhere (FR24).
…lift and the browser's menu, keeps a kept press's finger, and goes when the page hides

- A finger's or a pen's armed press now brings the claiming listeners
  (the cancelable touchend and the contextmenu canceller) with the page
  listeners, as wherever the host installs, so a claimed lift clicks
  nothing and a claimed hold opens no browser menu (spec:AST-094 FR21). A
  mouse's press needs neither and adds neither.
- A kept press keeps its finger under any pointer: its target blocks and
  its moves are cancelled once its press is delivered, not only where the
  primary pointer is coarse (FR11, DEC-21).
- A page hidden mid-press releases the armed press as a blur does, so the
  page's listeners go with the cancelled press (FR24).
…'s selection

An action group's press is a long press with no minimum duration, so it
claims at the press's pointerdown, before any movement. The drag
selection hold then cleared the page's selection, so a plain click on a
group's button left its Copy or Cut with nothing to act on. The case
fails on this head: the selection is gone after the pointerdown.
…age's selection

The drag selection hold cleared the page's selection when any mouse or
pen contact was claimed, but an action group's press claims at its
pointerdown, before it has moved or selected anything, so a plain click
on a group's button left its Copy or Cut with nothing to act on. A claim
before any movement now keeps the page's selection: the hold still
refuses a new one, and puts the page's back over a selection that
appears while it runs. A claim after movement clears what its drag
selected, as before (spec:AST-094 FR41).

The case before the new one now waits for its hold to end a task after
the lift, so no hold outlives that test.
…t every pointer does

- GestureHost's header, the gestures README and coarsePointer.ts no
  longer describe the coarse-only host: a finger or a pen takes the
  touch-down lock under any primary pointer, a kept press's finger is
  kept during a fine pointer's press too, and recognizers attach for
  every pointer while the page listeners last a press (spec:AST-094 FR19,
  FR24, FR41, FR43).
- The fine-pointer selection case asserted the replaced rule and passed
  only because it read iOS WebKit's inline lock without iOS WebKit: it
  now asserts the lock a fine pointer's finger and pen take, and that a
  mouse takes none.
- FR41's sentence and its verification row say that a press claimed at
  its own pointerdown, before it moves, keeps the page's selection, while
  a drag that moved before its claim loses what it selected.
…ion, in the present tense

The sentence said a drag that moved before its claim loses the selection
it started, but the hold clears the page's selection then, whatever made
it; and an unclaimed mouse drag kept "today's" behavior, a phrase that
ages. It now says a claim after movement clears the page's selection, a
press claimed at its own pointerdown keeps it, and an unclaimed mouse
drag keeps its released behavior.
meta#8665's review fixes: an armed press under a fine pointer brings the
claiming listeners, keeps a kept press's finger and goes when the page
hides; a press claimed at its own pointerdown keeps the page's selection;
FR41 says what a claimed drag does with the selection; meta#8360's
input-stream notes. The merge is clean.

@astracat-bot astracat-bot 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.

Approval waits on an engineering owner's decision about new drag-selection sentences in spec:AST-094 FR41, and the branch still needs splitting from the stack it depends on. This head adds to FR41 that a mouse or pen drag a recognizer claims takes no new text selection, clearing the page's selection once the drag has moved and keeping it when the press is claimed at its own pointerdown, which main does not state; an engineering owner approves it, with the amended verification row, by commenting /approve-spec 5760fdfb865d74afa00eb6a71b6918ddccd8e7fd on this pull request, or rejects it, and the author may record that decision as an engineering owner. Separately, the branch merges all of #8665 (about 28,400 changed lines) beside the five-file modifier-key change, so please land #8665 first and update this branch from main, which also takes the FR41 sentences out of this diff; the modifier-key change itself reads cleanly.

[Automated review]

Comment on lines +471 to +475
(`spec:AST-017` FR59; DEC-9). A mouse or pen drag that a recognizer claims
takes no new text selection for that drag only. A claim made after the
drag has moved clears the page's selection; a press claimed at its own
pointerdown, before it moves, keeps it. An unclaimed mouse drag keeps its
released behavior.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

These three sentences are new direction for a mouse and a pen, and they wait on an engineering owner's decision: FR41 on main keeps a mouse's released behavior (spec:AST-017 FR59; DEC-9), and FR59 also asks the pull request to name its reason and show fine-pointer evidence before and after. The amended verification row now matches them; the keep-at-pointerdown case has only a jsdom test so far (packages/core/src/gestures/__tests__/GestureHost.test.ts:4066).

vjeux added a commit to vjeux/astryx that referenced this pull request Oct 29, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 29, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 29, 2026
@vjeux
vjeux marked this pull request as draft October 29, 2026 22:39
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 30, 2026
…king ships only the press clocks

A gesture touch carries the modifier keys its press began with, so a
group can leave a Ctrl-, Cmd- or Shift-click on a link to the browser.
Press tracking reads only the timing table's press clocks, with a bundle
guard that keeps the host's module graph small
(architecture:component-style-authoring INV6), and the README documents
the recognizers' kind and the press tracker factory. meta#8763's and
meta#8797's changes, rebased onto meta#8309 as one commit; its press tracker
factory and recognizer kind now come with meta#8076 and meta#8071.
@vjeux

vjeux commented Oct 30, 2026

Copy link
Copy Markdown
Contributor Author

Folded into #8763 under the maintainers' section plan; the commit credits this PR's authors. Closing; the branch stays.

@vjeux vjeux closed this Oct 30, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 31, 2026
…king ships only the press clocks

A gesture touch carries the modifier keys its press began with, so a
group can leave a Ctrl-, Cmd- or Shift-click on a link to the browser.
Press tracking reads only the timing table's press clocks, with a bundle
guard that keeps the host's module graph small
(architecture:component-style-authoring INV6), and the README documents
the recognizers' kind and the press tracker factory. meta#8763's and
meta#8797's changes, rebased onto meta#8309 as one commit; its press tracker
factory and recognizer kind now come with meta#8076 and meta#8071.
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 31, 2026
…king ships only the press clocks

A gesture touch carries the modifier keys its press began with, so a
group can leave a Ctrl-, Cmd- or Shift-click on a link to the browser.
Press tracking reads only the timing table's press clocks, with a bundle
guard that keeps the host's module graph small
(architecture:component-style-authoring INV6), and the README documents
the recognizers' kind and the press tracker factory. meta#8763's and
meta#8797's changes, rebased onto meta#8309 as one commit; its press tracker
factory and recognizer kind now come with meta#8076 and meta#8071.
vjeux added a commit to vjeux/astryx that referenced this pull request Nov 1, 2026
…king ships only the press clocks

A gesture touch carries the modifier keys its press began with, so a
group can leave a Ctrl-, Cmd- or Shift-click on a link to the browser.
Press tracking reads only the timing table's press clocks, with a bundle
guard that keeps the host's module graph small
(architecture:component-style-authoring INV6), and the README documents
the recognizers' kind and the press tracker factory. meta#8763's and
meta#8797's changes, rebased onto meta#8309 as one commit; its press tracker
factory and recognizer kind now come with meta#8076 and meta#8071.

This branch was successfully deployed

1 active deployment
Preview — 5760fdfb Deployed Oct 29, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot. needs:design-review Affects visuals — Design should review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants