Skip to content

feat(Layer): one input hold, first used by Dialog, Drawer and MobileNav (spec:AST-094 FR35) - #8342

Merged
cixzhang merged 2 commits into
meta:mainfrom
vjeux:uikit/c18-presentation-hold
Nov 4, 2026
Merged

cixzhang merged 2 commits into
meta:mainfrom
vjeux:uikit/c18-presentation-hold

Conversation

@vjeux

@vjeux vjeux commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

Batch: the maintainers' section plan, section S3, row "hold #8342+#8630+#8374": the shared input hold with its first users, folding in #8630 (Dialog and Drawer) and #8374 (MobileNav). Both close with a link here, and the commit credits their authors.

Why

On a phone, a tap or a drag can land while a Dialog, a Drawer or the MobileNav drawer is still entering, and reach whatever is under the finger: a backdrop tap closes the dialog, a button runs mid-entrance, a drag scrolls the page. spec:AST-094 FR35 asks a touch presentation to take no pointer input while it enters. Keys, assistive technology and a mouse stay live (DEC-16), and around a non-modal presentation only its own box is held.

Options and tradeoffs

  1. Each component blocks its own input: several holds that drift apart.
  2. Block all input during an entrance: that holds keys, screen readers, a mouse and the page around a non-modal layer, which FR35 keeps live.
  3. One hold in the Layer module that decides by each event's pointer.

Why this choice

Option 3. Layer/inputHold (internal) adds holdInput(layer, {until, scope}):

  • 'viewport' shields the viewport while a modal layer enters, 'surface' only the layer's box, and 'layer' the viewport while a layer leaves, its content inert. It ends when until settles, so it never times an exit.
  • A finger's or a pen's press is held to its end. A mouse passes once it moves, or from the start when the page last heard a mouse.
  • A touch's start is never cancelled, so a pinch still zooms (FR10).
  • It hears the shared input stream first and ends the dispatch for a held event, so press feedback and the gesture host never hear it. It imports nothing from gestures/ (architecture:react-component-runtime INV13).

Its first users hold only under a coarse primary pointer (FR13): Dialog and Drawer while their entrance animations run, a Drawer without a scrim only on its panel, and MobileNav until its slide-in ends.

Known gaps (AST-094's Current-state impact): a finger on a touchscreen laptop isn't held, and after a finger, a still mouse's first press lands on an entering presentation's shield. AlertDialog renders Dialog, so its entrance is held too; its record says FR7 ships in part.

How to check it

In phone emulation:

  1. Dialog: tap the backdrop while it is still entering. Nothing happens, and it finishes opening. A button tapped mid-entrance does nothing, and works once settled.
  2. Drawer: the same with a scrim; a drag mid-slide scrolls nothing. On the Row Inspector (no scrim), a tap on the panel mid-slide does nothing, while the page beside it scrolls and takes taps.
  3. AppShell: tap a link while the mobile nav slides in. Nothing happens; once it is in, links work.
  4. With a mouse: a click during an entrance acts as before, once the mouse has moved after a finger. Escape always closes.

Evidence

  • fbe71df is 80ffcbb rebased onto main f10bc91 (spec(AST-094): only a gesture's motion springs; other motion plays the theme's tokens #9706), with AppShell left as it is on main. The one conflict is AlertDialog.spec.md's tables, where no row changed on both sides. At fbe71df: check:knowledge, check:repo, core typecheck and the touched units (2,453) pass, and so do the MobileNav accessibility spec (6) and the Chromium interaction specs (30).
  • AppShell's mobile-nav close is visibly the same on main and on 80ffcbb with a mouse. In Chromium, with the story's 300 ms slide and the default 410 ms one, the drawer slides fully out and disappears at the same moment on both. Only the moment AppShell hides its Activity boundary moved (308 ms to 610 ms), and the closing dialog's display transition outranks that hide. So this pull request no longer changes AppShell.
  • 80ffcbb is 616c6a2 without its two CI files: the WebKit job's path filter line in ci.yml and its routing test moved to ci: run the WebKit interaction proofs when the input hold changes #9906, which merges first (the routing test requires every file the gesture specs bundle, Layer/inputHold.ts included, to be in that filter). The product, specs and tests are byte-identical.
  • 616c6a2 is c63f2f9 rebased onto main 217fc46, with the four browser specs importing @astryxdesign/a11y-spec/playwright (the product patch is byte-identical). At 616c6a2: CI=true pnpm lint reports only main's own WheelContrast.a11y.chromium.spec.ts; build, typecheck, the Chromium interaction specs (30) and the MobileNav accessibility spec (8), WebKit in CI's container (26, 4 Chromium-only skipped), the touched units (2,453) and check:knowledge pass.
  • Gates at 09ee02b (on main f03257e): typecheck, check:repo, check:knowledge, strict ESLint and Prettier clean; pnpm test:ui 19,523 passed; the node project 16,164 passed (2 expected failures); api:report --check clean; Storybook built.
  • Browser: the input hold, Dialog and Drawer entrance, gesture and presented-hit-testing specs pass 102/102 in Chromium twice and pinned to 2 cores with 4 workers, and 270/270 in WebKit in CI's container (×3, 1 worker). MobileNav's touch-presentation and the modal-backdrop accessibility specs pass 37/37.
  • Red-first: the stream's first-subscriber case and the hold's "a held press reaches no other subscriber" case fail on the previous stream and hold. Each earlier case failed before its change: a fine-pointer entrance took no press where the mouse rested, the page beside a non-modal drawer was held, and MobileNav's close held its drawer.
  • This PR changes no public API. deepActiveElement moves to utils/flatTree.ts, shared by the hold and the selection lock.
  • One take each: every browser case lands its input at an exact point, and none retries.
  • A mouse during the entrance, main 0588a13 against this head 72f6726, on each commit's own Vercel build, in Chromium 149 (headless), 3 runs per build and case. A mouse opens Dialog (Info), Drawer (Showcase) and MobileNav (Default), then clicks Got it, Close and Dashboard while the entrance still runs.
    • Under a fine primary pointer ((pointer: coarse) false, as on a touchscreen laptop), no hold starts. Both builds give the same events (pointerdown, mousedown, pointerup, mouseup and click on the control) and the same result: the dialog and the drawer close, and MobileNav follows its link. The entrance, sampled at 12 paused times from 0 to 600 ms, differs by 0 px, with the same opacity and transform.
    • Under Chromium's touch emulation ((pointer: coarse) true), a mouse that opened the layer gets the same events, results and 0 px.
    • The recorded gap shows only under a coarse primary pointer: after a finger opens the layer, a mouse click with no move first lands on the shield. The Info dialog stays open, where main closes it.

Writing rubric results

Records touched: component:Drawer (Current-state impact: the entrance-hold gap, now only the fine-primary case), spec:AST-094 (path applicability for the two Layer files; Current-state impact: the fine-primary gap and the still mouse after a finger), component:Dialog (system_specs names FR35), component:AlertDialog (Current-state impact, the readiness row, FR7's gap note and verification row, and verified_by: the entrance hold ships through Dialog), and component:MobileNav (FR5: one sentence on the rest of the slide-out after the close; one verification row, its verified_by entry and system_specs).

  • R1 narration: none; each edit states what the system does today.
  • R2 research: none.
  • R3 evidence: none; the new MobileNav verification row names the browser case and what it checks, not a change.
  • R4 rejected alternatives: none added.
  • R5 change references: none added.
  • R6 open questions: none touched.

@vercel

vercel Bot commented Oct 25, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
astryx Ready Ready Preview Nov 4, 2026 12:12pm UTC

Request Review

@astracat-bot

astracat-bot Bot commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Nov 4, 2026, 12:22 PM UTC

@github-actions github-actions Bot added the needs:design-review Affects visuals — Design should review label Oct 25, 2026
@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Oct 25, 2026
@vjeux
vjeux force-pushed the uikit/c18-presentation-hold branch from cf96937 to 5f964ec Compare October 25, 2026 02:40
@vjeux
vjeux force-pushed the uikit/c18-presentation-hold branch from 5f964ec to bc6133a Compare October 25, 2026 03:53

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This pull request is on hold for a spec decision: the shared presentation hold in packages/core/src/Layer/presentationHold.ts and the draft spec:AST-094 requirements (FR1–FR23) have no current governing record, so an engineering owner needs to settle whether the Layer module owns one hold for every layer (today architecture:layer-runtime INV9 keeps pointer and touch handlers with their owning component), identify the record that carries it, and approve or reject AST-094 by commenting /approve-spec <full head SHA>; the author may do so as an engineering owner. Independently of that decision, these changes are needed:

  • Land the six dependency pull requests and rebase, so the compare against main holds only the presentation-hold commit instead of 37,287 authored lines.
  • Release the usePressFeedback text-selection lock with a non-experimental changeset, since spec:AST-017 FR22 keeps stable runtime behavior out of [experimental].
  • Remove the platform adaptation axis from packages/cli/assets/theme.template.ts, which defineTheme rejects.
  • Let the status effect in useAlertMotion release its own hold, so StrictMode's effect replay acquires it again.
  • Correct the two changesets that misstate the focus change and the theme md point.

[Automated review]

Comment thread packages/cli/assets/theme.template.ts Outdated
Comment on lines +361 to +362
* are primary-pointer precision, contrast preference, motion preference, and
* platform (`ios` for the browsers of iPhone and iPad, `other` for the rest).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This lists a platform adaptation axis, but defineTheme refuses when: {platform: 'ios'} (packages/core/src/theme/expandMotionRoles.test.ts:137-145) and spec:AST-012 FR2 closes when to width, height, pointer, contrast and motion. A theme author who follows this gets a build error; drop the platform clause.

Comment thread .changeset/touch-selection-lock.md Outdated
'@astryxdesign/core': patch
---

[experimental] A held finger or pen no longer starts text selection or the iOS callout on a press-tracked control or cell, or on a recognizer created with the new `preventsTextSelection` option (`@astryxdesign/core/gestures/experimental`), as on UIKit controls. The gesture host turns them off for the contact and restores them when it ends, on a non-text input under the finger inside such an element too (WebKit's own style makes every input selectable), and only for recognizers that would take the touch; the document press controller (`usePressFeedback` rows) takes the same lock on any coarse pointer. Text fields keep their selection, a mouse keeps desktop selection, and other pans leave their content selectable.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The usePressFeedback part of this changes stable runtime behavior on Button, Link, Item and every other press-feedback surface under a finger, which spec:AST-017 FR22 keeps out of [experimental]. Give that part its own [fix] or [feat] entry naming the stable change.

Comment on lines +204 to +211
// Unmounting lets the alert go at once, hold included.
useEffect(
() => () => {
releaseHoldRef.current?.();
releaseHoldRef.current = null;
},
[],
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Under <StrictMode> this cleanup runs in the effect replay and releases the hold that the status effect below acquired; for an alert mounted open, the replayed status effect returns at was === now, so the hold never comes back in development (architecture:react-component-runtime INV3). Letting the status effect own the release of its hold would make the replay re-acquire it.

Comment thread .changeset/layer-presentation-hold.md Outdated
'@astryxdesign/core': patch
---

[chore] Internal: one presentation hold for every layer (`Layer/presentationHold`). While a layer presents, a touch, a press or a click anywhere reaches nothing (keys and assistive technology still act); while it dismisses, the same, and the layer's content is inert. The gesture host holds the layer's touches through its touch gates. AlertDialog's touch presentation now uses it, with no change in behavior. Not exported from the package entry point.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Focus now leaves the fading alert's content (its children become inert), as the pull request description says, so “no change in behavior” undersells it; name the focus change.

// and large touch displays stay anchored, phones in either orientation get
// the sheet. The query is the shared touch presentation's
// (utils/touchPresentation.ts).
export const COMPACT_TOUCH_PRESENTATION_QUERY = COMPACT_TOUCH_QUERY;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is the default compact definition, so a theme that overrides md still gets 768 px here, while .changeset/touch-presentation.md promises the theme's md point (spec:AST-012 FR9 asks for the effective md). Either read the active theme's definition or say 768 px in the note.

@vjeux
vjeux force-pushed the uikit/c18-presentation-hold branch from bc6133a to 77d538e Compare October 25, 2026 05:30
@vjeux vjeux changed the title refactor(Layer): one presentation hold for every layer, AlertDialog first feat(Layer): one presentation hold for every layer Oct 25, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 25, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 25, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 25, 2026
Under a finger AlertDialog held its own input during its presentation:
the action tracker dropped touches through an isInert predicate, and
render guards dropped a click, Enter or Escape while it left. It now uses
the Layer module's one hold (meta#8342):

- useAlertMotion holds the alert with scope 'viewport' from the
  presentation's start until UIKit's touch window after the start the
  browser reports (alertController.present.ignoresTouchesForMs, a
  cancellable timer), or until the reduced-motion cross-fade ends; and
  with scope 'layer' until the dismissal's fade ends. acceptsTouches
  reads the hold.
- AlertDialogTouch drops the tracker's isInert predicate: the gesture
  host's touch gate holds the alert's touches. AlertDialog drops its
  click guards; the Escape guard stays, since inert content does not
  stop the dialog's own cancel event.

One detail moves: while the alert fades out, focus leaves its inert
content, and Dialog still returns it to the opener at the end. The leave
test now also checks what a tap hits (the shield, on the alert and over
the opener) and a key during the exit (focus already out, nothing runs).
@vjeux
vjeux force-pushed the uikit/c18-presentation-hold branch from d7f968b to 2eec9ff Compare October 25, 2026 07:27

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This pull request is on hold for a spec decision. No current record states a shared presentation hold in the Layer module (packages/core/src/Layer/presentationHold.ts), and architecture:layer-runtime INV9 keeps backdrop, pointer and touch handlers with their owning component. An engineering owner needs to provide or identify the record that states the hold and its shield, and to record that decision by commenting /approve-spec <full head SHA> on this pull request; the author may do so as an engineering owner. Whatever that decision is, these changes are needed:

  • Land #8045, #8062, #8074, #8075 and #8336 and rebase, so that the compare against main holds only the presentation-hold commits instead of 32,819 authored lines.
  • Give the velocity tracker the stillness reset that spec:AST-100 FR2 requires, and drop its alternative filters and the velocityStrategy option (architecture:interaction-modality INV10); PanGestureRecognizer.test.ts currently expects 2,000 px/s after a still pause.
  • File .changeset/reduced-motion-record.md as an internal change, since the animator it describes is not exported.
  • Either limit the press-feedback selection lock to touch, or show the before-and-after pen evidence that spec:AST-017 FR59 asks for.

[Automated review]

* header describes, `uikit` by default.
* @experimental
*/
export type VelocityStrategy = 'uikit' | 'lsq2' | 'iosFling' | 'window';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

spec:AST-100 (current) says the first Core velocity source has "no selectable alternative filters", and architecture:interaction-modality INV10 keeps one release filter. FR2 also requires a release of 0 after a still finger, while the uikit filter "never decays" (lines 13-20) and PanGestureRecognizer.test.ts:77-92 expects 2,000 px/s after a 100 ms rest. Keeping only uikit, adding the stillness reset and removing velocityStrategy would conform.

Comment thread .changeset/reduced-motion-record.md Outdated
'@astryxdesign/core': patch
---

[fix] The experimental touch animator follows design:reduced-motion. Under Reduce Motion every `animate` and `play` (travel or opacity, on a spring or any curve) becomes an opacity-only cross-fade of 200 ms on a coarse primary pointer, or for a call marked `touchRelease`, and completes at once on any other pointer; nothing moves, scales or springs, and travel lands with a plain style write while the element is transparent, never as an animated transform. `timing.reducedMotionCrossFadeMs` is now 200 ms and is no longer a theme role. The `reducedMotion` and `crossfade` call options are removed; `crossfadeAs` stays. `animator.reducedMotion()` reports the mode for feedback with no fade of its own, `reducedMotionFade()` gives the same fade to code beside the animator, and `reducedMotion.stylex.ts` holds its constants for StyleX transitions.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The animator has no export path at this head, and uikit-motion-animator-curves.md already records this behavior as [chore] Internal. As a [fix] naming removed reducedMotion and crossfade options, this reads as a change to an API consumers could import. Filing it as [chore] Internal would match its siblings.

Comment thread packages/core/src/Layer/inputHold.ts Outdated
if (!isHeld || layer == null) {
return;
}
// Released by the cleanup alone.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

architecture:react-component-runtime INV3 asks a new Effect to name the external system it synchronizes. A comment saying it synchronizes the layer's hold (its shield, its content's inert, the gesture host's gate) with isHeld would cover it; the acquire and release pairing is already right.

@@ -283,6 +301,9 @@ function onPointerDown(event: PointerEvent): void {
gesture: idlePressGesture(),
pointerId: event.pointerId,
timer: null,
// A held finger never starts text selection on the surface (UIKit's
// controls and cells never do), on any coarse pointer.
unlock: lockSelection([element], event.target),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This lock also runs for a pen (only mouse returns early at line 270), and a pen is a fine pointer under spec:AST-017 FR59, which asks for before-and-after fine-pointer evidence. Either limit the lock to pointerType === 'touch', or keep pen and show that evidence.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

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 spec decision: no current record states the input hold's dismissal scope (scope: 'layer', which nothing calls yet), and the stacked commits add new requirements to spec:AST-094 FR11 and FR19, architecture:react-component-runtime INV13, component:Drawer FR5, component:BottomSheetSwitcher FR5 and component:MobileNav FR3. An engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request (the author may do so as an engineering owner); otherwise the scope can be dropped and the record edits can land through #8360 and #8098 under their own approval. Whatever is decided, these still need changes:

  • Land the four stacked pull requests and rebase, so this compare is the input-hold commit alone (24,463 authored lines today, 19,753 of them stacked).
  • Have MobileNav call holdEntranceInput instead of its own copy, and route the hold's window listeners through the shared input stream.
  • Keep a resting mouse's first press during an entrance on a phone or tablet, which spec:AST-094 FR35 requires ("A mouse keeps today's behavior").
  • Correct the changesets and docs that say "On a touch screen" and promise that a mouse "still clicks", and the Chinese MobileNav text that says a close holds input.

[Automated review]

// entrance only. Under a mouse nothing is held. The pointer is the one
// condition this presentation names (AST-094 FR13), read as the drawer
// opens.
const isTouch =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This repeats what holdEntranceInput (Layer/holdEntranceInput.ts:51-72) does for Dialog and Drawer: a coarse-pointer read here and slideInEnd (lines 383-401) waiting on the drawer's animations, then holdInput at 673-678. The two already differ (the helper keeps only running, finite animations and has no cap). Let the helper take the element whose entrance it reads and an optional cap, and call it once here.

Comment thread packages/core/src/Layer/inputHold.ts Outdated
}
}

for (const type of POINTER_EVENTS) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

These 14 window capture listeners sit beside utils/inputStream.ts, which this compare makes the one entry for document-wide input listeners. Press feedback subscribes on every pressable's mount, so the stream's listeners are registered first and hear a held press before this swallows it, which contradicts the header at lines 53-55. Subscribing through the stream (first position, able to end the dispatch) would also give the hold a pointer it can read before a hold starts.

Comment thread packages/core/src/Layer/inputHold.ts Outdated
state.shield = addShield(layer, state.isShieldFull);
// A presentation held while a mouse is the latest pointer heard lets it
// through from the start.
if (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

latestPointer is cleared with the document's last hold (lines 268-269), so on a phone or tablet with a mouse every entrance starts with the shield taking hits, and a press where the mouse already rests meets it (DialogTouchEntrance.interaction.browser.spec.ts:394-402). spec:AST-094 FR35 keeps a mouse's behavior unchanged, and its verification row fails a changed mouse path.

'@astryxdesign/core': patch
---

[fix] On a touch screen, a tap or a drag that lands while a `Dialog` or a `Drawer` is still entering no longer closes it, runs one of its actions, moves focus or scrolls the page: it reaches nothing until the entrance has played, and the dialog or drawer finishes entering and stays open. Around a `Drawer` without a scrim only a tap or a drag on the panel is held, and the page stays live. Keys and assistive technology stay live during the entrance, the content stays in the accessibility tree, a pinch still zooms the page, and a mouse or a trackpad still clicks.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

"On a touch screen" also covers a touchscreen laptop, whose finger is not held (DialogTouchEntrance.test.tsx:135), and "a mouse or a trackpad still clicks" does not hold for a press before the mouse moves on a coarse-primary device. Suggest "On a phone or tablet (a coarse primary pointer)" and "still clicks once it has moved"; the same wording is in Dialog.doc.mjs:72 and Drawer.doc.mjs:175.

type: '(isOpen: boolean) => void',
description:
'当抽屉可见性变化时调用(点击背景遮罩、按 Escape 键或点击关闭按钮)。',
'当抽屉可见性变化时调用(点击背景遮罩、按 Escape 键或点击关闭按钮)。使用手指操作时,抽屉滑入期间不接受输入;关闭后、对话框关闭之前也不接受输入,随后焦点回到打开它的元素。',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This Chinese text says the drawer also takes no input after a close until the dialog closes, but a close holds nothing at this head (MobileNav.tsx:643-645, MobileNavTouchPresentation.test.tsx:185). Translating the English description would fix it.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

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 spec decision: no current record states the input hold's dismissal scope (scope: 'layer', which nothing calls yet), and the stacked commits write new requirements into spec:AST-094 FR11 and FR19, architecture:react-component-runtime INV13, component:Drawer FR5, component:BottomSheetSwitcher FR5 and component:MobileNav FR3. An engineering owner records that decision by commenting /approve-spec with the full head SHA (the author may, as an engineering owner), or the scope is dropped and the record edits land through their own pull requests. Whatever is decided, these still need changes:

  • Land #8360, #8098, #8309 and #8763 first, so this compare is the input hold's own 33 files rather than 24,544 lines.
  • Keep a resting mouse's first press during an entrance on a phone or tablet, as spec:AST-094 FR35 requires (inputHold.ts:372-383).
  • Have MobileNav call holdEntranceInput, route the hold's window listening through the shared input stream, and use containsAlongFlatTree instead of the hold's two copies.
  • Correct the changesets and docs that say "On a touch screen", promise a mouse still clicks, or describe a hold on MobileNav's close.

[Automated review]

Comment thread packages/core/src/Layer/inputHold.ts Outdated
state.shield = addShield(layer, state.isShieldFull);
// A presentation held while a mouse is the latest pointer heard lets it
// through from the start.
if (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

latestPointer is dropped with the document's last hold (lines 268-269), so on a phone or tablet with a mouse every entrance starts with the shield taking hits, and a press where the mouse already rests lands on it (DialogTouchEntrance.interaction.browser.spec.ts:394-402 asserts this). spec:AST-094 FR35 says "A mouse keeps today's behavior". Start the shield lowered from a pointer source that already runs (the input stream's pointer channels), or have FR35 amended.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Same fix as on the later review, at 0ac5f7a: the shield starts lowered when the stream last heard a mouse.

Comment thread packages/core/src/Layer/inputHold.ts Outdated
}
}

for (const type of POINTER_EVENTS) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

These 14 window capture listeners sit beside utils/inputStream.ts, which this compare makes the one entry for document-wide input listeners. On a page with a pressable, the stream's listeners were registered first, so press feedback and the other subscribers hear a held press before this swallows it (a probe on the head confirms the order). Extending the stream with a first-position subscriber that can end the dispatch would keep one listener path.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Same fix as on the later review, at 0ac5f7a: the hold is a first subscriber of the shared stream and ends the dispatch for a held event.

Comment thread packages/core/src/Layer/inputHold.ts Outdated
}

/** Whether `target` is `layer` or inside it, in the flat tree. */
function isWithin(layer: Element, target: EventTarget | null): boolean {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This and isInFlatTree (line 809) re-implement containsAlongFlatTree from utils/flatTree.ts, which the module already imports from. Import it and delete both copies, so a fix to the shared walk reaches the 'surface' hit check and the dismissal's focus check.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Same fix as on the later review, at 0ac5f7a: containsAlongFlatTree replaces both copies.

* open, so such a timer ends the hold before the slide-in does, and under
* load it can also run after it.
*/
async function slideInEnd(drawer: HTMLElement | null): Promise<unknown> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This and the coarse-pointer read at line 648 repeat what holdEntranceInput (Layer/holdEntranceInput.ts:51-72) does for Dialog and Drawer, with a 550 ms cap the helper lacks. Let the helper take the element whose entrance it reads and an optional cap, and call it once here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Same fix as on the later review, at 0ac5f7a: MobileNav calls holdEntranceInput with its entrance element and cap.

'@astryxdesign/core': patch
---

[fix] On a touch screen, a tap or a drag that lands while a `Dialog` or a `Drawer` is still entering no longer closes it, runs one of its actions, moves focus or scrolls the page: it reaches nothing until the entrance has played, and the dialog or drawer finishes entering and stays open. Around a `Drawer` without a scrim only a tap or a drag on the panel is held, and the page stays live. Keys and assistive technology stay live during the entrance, the content stays in the accessibility tree, a pinch still zooms the page, and a mouse or a trackpad still clicks.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

"On a touch screen" also covers a touchscreen laptop, whose finger is not held (DialogTouchEntrance.test.tsx:135), and a resting mouse's first press is held on a phone or tablet. Say "On a phone or tablet" and "a mouse or a trackpad still clicks once it has moved".

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Same fix as on the later review, at 0ac5f7a.

type: '(isOpen: boolean) => void',
description:
'当抽屉可见性变化时调用(点击背景遮罩、按 Escape 键或点击关闭按钮)。',
'当抽屉可见性变化时调用(点击背景遮罩、按 Escape 键或点击关闭按钮)。使用手指操作时,抽屉滑入期间不接受输入;关闭后、对话框关闭之前也不接受输入,随后焦点回到打开它的元素。',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This Chinese text says the drawer also takes no input after a close until the dialog closes, but a close holds nothing at this head (MobileNav.tsx:679-687). Translate the English description instead.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Same fix as on the later review, at 0ac5f7a.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

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 on new record text: the added component:MobileNav FR5 sentence about the slide-out after the dialog closes, and the stacked #8098 edits to component:Drawer FR5, component:BottomSheetSwitcher FR5 and component:MobileNav FR3. An engineering owner can accept them by commenting /approve-spec <full head SHA> on this pull request (the author may do so as an engineering owner), or #8098's text can land through #8098 first. Whatever that decision, these need changes:

  • Land #8098, #8309 and #8763 first and split the two adopters, so the compare is no longer 7,671 lines.
  • Keep a resting mouse's press during an entrance on a coarse-primary device (inputHold.ts:372-383), as FR35's "A mouse keeps today's behavior" requires.
  • Have MobileNav call holdEntranceInput, route the hold's window listening through utils/inputStream.ts, and use containsAlongFlatTree instead of the two private flat-tree walks.
  • Correct the "On a touch screen … a mouse or a trackpad still clicks" wording in the changesets and docs, the Chinese MobileNav onOpenChange text, and the Drawer gap heading.

[Automated review]

Comment thread packages/core/src/Layer/inputHold.ts Outdated
Comment on lines +372 to +377
if (state.shield == null) {
state.isShieldFull = isFull || state.viewport + state.layer > 0;
state.shield = addShield(layer, state.isShieldFull);
// A presentation held while a mouse is the latest pointer heard lets it
// through from the start.
if (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No pointer is known when a hold starts, and latestPointer is dropped with the document's last hold (lines 268-269), so on a phone or tablet with a mouse every entrance starts with the shield taking hits: a press where the mouse already rests reaches nothing (DialogTouchEntrance.interaction.browser.spec.ts:398-402 asserts it). spec:AST-094 FR35 keeps today's mouse behavior and its verification row fails a changed mouse path. Start the shield lowered when the last pointer heard is a mouse, from a source that already runs (the shared input stream), or have FR35 record the limit.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed at 0ac5f7a: a presentation's shield starts lowered when the shared input stream last delivered a mouse's pointer event, so a press where a resting mouse already is acts during an entrance. A finger or a pen raises it again. DialogTouchEntrance.interaction.browser.spec.ts now asserts that the resting mouse's press acts.

Comment thread packages/core/src/Layer/inputHold.ts Outdated
}
}

for (const type of POINTER_EVENTS) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

These 14 window capture listeners and latestPointer sit beside utils/inputStream.ts, "The one entry for document-wide input listeners in core". Press feedback subscribes on every pressable's mount (usePressFeedback.ts:53), so the stream's window listeners are registered first and hand each held event to every subscriber before this one swallows it, which also makes the header's "neither the page nor the gesture host hears it" (lines 53-55) inaccurate. Subscribe through the stream with a first-position subscriber that can end the dispatch, or say why it cannot.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done at 0ac5f7a: the hold subscribes through utils/inputStream.ts as a first subscriber ({first: true}, new in the stream). It hears each event before every other subscriber, and its true ends the dispatch for a held event, so press feedback and the gesture host never hear it. Unit cases cover both sides and fail on the previous stream and hold.

Comment thread packages/core/src/Layer/inputHold.ts Outdated
}

/** Whether `target` is `layer` or inside it, in the flat tree. */
function isWithin(layer: Element, target: EventTarget | null): boolean {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This and isInFlatTree (line 809) re-implement containsAlongFlatTree from utils/flatTree.ts, which line 100 already imports from. Use it and delete both copies, keeping the instanceof Node guard at the call site, so a fix to the shared walk reaches the 'surface' hit check and the dismissal's focus check.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done at 0ac5f7a: both private walks are gone, and the 'surface' hit check and the dismissal's focus check use containsAlongFlatTree, keeping the instanceof Node guard at the call site.

* open, so such a timer ends the hold before the slide-in does, and under
* load it can also run after it.
*/
async function slideInEnd(drawer: HTMLElement | null): Promise<unknown> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This and the coarse-pointer read at lines 648-650 repeat what holdEntranceInput (Layer/holdEntranceInput.ts:51-72) does for Dialog and Drawer, with a 550 ms cap and no running/finite filter, so the two entrance holds already differ. Let the helper take the element whose entrance it reads and an optional cap, and call it once here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done at 0ac5f7a: holdEntranceInput takes the element whose entrance it reads and an optional cap, and MobileNav calls it once ({entrance: drawer, capMs: MAX_EXIT_MS}), so the two entrance holds no longer differ.

Comment thread packages/core/src/Dialog/Dialog.doc.mjs Outdated
},
description:
'Modal dialog using the native <dialog> element. Modal and inline content starts with theme body text defaults. Ancestor surface/group membership ends as a whole, including group-owned state. Explicit props and unrelated contexts remain unchanged. Place intentional groups and complete required providers inside the dialog. With reduced motion the dialog never slides or scales in: on a touch screen it fades in, in 200 ms or less, and with a mouse it appears at once.',
'Modal dialog using the native <dialog> element. Modal and inline content starts with theme body text defaults. Ancestor surface/group membership ends as a whole, including group-owned state. Explicit props and unrelated contexts remain unchanged. Place intentional groups and complete required providers inside the dialog. With reduced motion the dialog never slides or scales in: on a touch screen it fades in, in 200 ms or less, and with a mouse it appears at once. On a touch screen, a tap or a drag that lands while the dialog is still entering reaches nothing, and the dialog finishes entering; keys and assistive technology stay live, and a mouse or a trackpad still clicks.',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

"On a touch screen" also covers a touchscreen laptop, whose finger is not held (DialogTouchEntrance.test.tsx:135-142), and "a mouse or a trackpad still clicks" misses a resting mouse's first press on a phone or tablet. Say "On a phone or tablet (a coarse primary pointer)" and "still clicks once it has moved"; the same wording is in Drawer.doc.mjs:175 and .changeset/dialog-drawer-touch-entrance-hold.md.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed at 0ac5f7a: "On a phone or tablet (a coarse primary pointer)". With the resting-mouse fix, the mouse clause now reads "a mouse or a trackpad still clicks: at once when it was the last pointer used, otherwise once it moves", here, in Drawer.doc.mjs and in the changeset.

type: '(isOpen: boolean) => void',
description:
'当抽屉可见性变化时调用(点击背景遮罩、按 Escape 键或点击关闭按钮)。',
'当抽屉可见性变化时调用(点击背景遮罩、按 Escape 键或点击关闭按钮)。使用手指操作时,抽屉滑入期间不接受输入;关闭后、对话框关闭之前也不接受输入,随后焦点回到打开它的元素。',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This Chinese text says the drawer also takes no input after a close until the dialog closes, but a close holds nothing at this head (MobileNav.tsx:679-686, MobileNavTouchPresentation.test.tsx:185-206). Translate the English description instead.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed at 0ac5f7a: the Chinese text now translates the English description, and says nothing about a hold after a close.

@cixzhang

cixzhang commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

/approve-spec 85da41d

@cixzhang

cixzhang commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

/approve-spec 0ac5f7a

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Changes requested. The input hold does what spec:AST-094 FR35 asks for touch users, and the new MobileNav FR5 sentence is owner-approved at this head. Five things still need fixing:

  • At 4,946 authored lines with two separable adopters folded in, land the hold with one adopter and the other after it, or, if this is an owner-directed batch, name its row in the description.
  • component:AlertDialog still says alerts do not hold their entrance (AlertDialog.spec.md:87-88, plus FR7's gap note, the readiness row and FR7's verification row), although AlertDialog renders Dialog and now holds its entrance under a coarse primary pointer.
  • After a finger, the first press of a mouse that has not moved lands on the shield during an entrance (inputHold.ts:290-296, asserted in inputHold.interaction.browser.spec.ts:283-309), against FR35's "A mouse keeps today's behavior", so record it as an FR35 gap in Current-state impact or conform.
  • holdEntranceInput.ts:59-63 re-implements the coarse-pointer read that isCoarsePointer in utils/coarsePointer.ts owns, so call it instead.
  • .changeset/mobile-nav-touch-presentation.md and MobileNav's onOpenChange docs say "under a finger" (and "no input") though only a coarse primary pointer is held and keys stay live, and .changeset/layer-input-hold.md says a press "anywhere" is held though a 'surface' hold leaves the page live.

[Automated review]

Comment on lines +59 to +63
if (
layer.ownerDocument.defaultView?.matchMedia?.(TOUCH_QUERY).matches !== true
) {
return null;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This repeats isCoarsePointer(view) from utils/coarsePointer.ts, the shared gate for touch-only work. Calling it keeps this entrance hold in step with every other coarse-pointer gate:

if (!isCoarsePointer(layer.ownerDocument.defaultView)) {
  return null;
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done at 7826488: it calls isCoarsePointer(layer.ownerDocument.defaultView), and the TOUCH_QUERY read and its import are gone.

Comment on lines +290 to +296
state.isShieldFull = isFull || state.viewport + state.layer > 0;
state.shield = addShield(layer, state.isShieldFull);
// A presentation lets a mouse through from the start when the stream's
// last pointer was a mouse. A press where the mouse rests then acts.
if (scope !== 'layer' && isMouseLast(layer.ownerDocument)) {
state.shield.style.pointerEvents = 'none';
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

After a finger or a pen, the first press of a mouse that has not moved lands on the shield (asserted at inputHold.interaction.browser.spec.ts:283-309). spec:AST-094 FR35 says "A mouse keeps today's behavior", and its verification row fails "a changed mouse path". Record this limit as an FR35 gap in AST-094's Current-state impact, beside the touchscreen-laptop sentence, or conform.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Recorded at 7826488 as an FR35 gap in AST-094's Current-state impact, beside the touchscreen-laptop sentence: "After a finger, the first press of a mouse that has not moved since lands on the entering presentation's shield and does nothing, where FR35 keeps today's behavior for a mouse (DEC-16); a move of the mouse lowers the shield first." The press has hit the shield before its pointer type is known, so conforming would mean synthesizing its down, up and click on the element beneath.

'@astryxdesign/core': patch
---

[fix] Under a finger, MobileNav takes no touch until its slide-in ends. Under a mouse nothing changes.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The hold runs only under a coarse primary pointer (holdEntranceInput.ts:59-63), so a touchscreen laptop's finger is not held. On a phone or tablet, an unmoved mouse after a finger is held. Suggested wording, matching Dialog and Drawer: "On a phone or tablet (a coarse primary pointer), a tap or a drag during MobileNav's slide-in reaches nothing; keys and assistive technology stay live, and a mouse or a trackpad still clicks: at once when it was the last pointer used, otherwise once it moves."

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done at 7826488: the changeset now uses your sentence as written. MobileNav's onOpenChange docs (English, dense and Chinese) and its two source comments say a coarse primary pointer, with keys and assistive technology live.

Comment thread .changeset/layer-input-hold.md Outdated
'@astryxdesign/core': patch
---

[chore] Internal: one input hold for every layer (`Layer/inputHold`). While a layer presents, a finger's or a pen's touch, press or click anywhere reaches nothing, and a mouse passes once it moves, at once when the page last heard a mouse (keys and assistive technology still act, and a pinch still zooms the page). While it dismisses, every press and click reaches nothing, and the layer's content is inert. The gesture host asks the hold about each touch (`holdsTouch`), and `isInputShield` lets a hit test see past a hold's shield. Not exported from the package entry point; Dialog's and Drawer's entrance holds and MobileNav's drawer use it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

A 'surface' hold, which a Drawer without a scrim uses, holds only the layer's box and leaves the page live (inputHold.ts:460-485). Say "anywhere around a modal layer, and only on the layer around a non-modal one".

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done at 7826488: it now says the press is held "anywhere around a modal layer, and only on the layer itself around a non-modal one, whose page stays live".

@cixzhang

cixzhang commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

/approve-spec 7826488

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Recommending approval. The input hold does what spec:AST-094 FR35 asks for Dialog, AlertDialog, Drawer and MobileNav under a coarse primary pointer. An engineering owner approved the new record text for this head, and the size is covered as an owner-directed batch. The unit tests and the Chromium browser specs for the hold, Dialog, Drawer and MobileNav pass at this head.

Optional, non-blocking:

  • Dialog.tsx:646 and useDrawerDialogPresence.ts:152 say "Under a finger", but the hold runs only under a coarse primary pointer; "Under a coarse primary pointer" would be accurate.
  • The comments at holdEntranceInput.ts:71 and inputHold.ts:349 restate the code beside them and can go.

[Automated review]

Comment thread packages/core/src/Dialog/Dialog.tsx Outdated
focusForLastInput(autofocusTarget);
}
}
// Under a finger, a tap or a drag during the entrance reaches nothing.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking: the hold runs only under a coarse primary pointer, so a finger on a touchscreen laptop is not held. "Under a coarse primary pointer, a tap or a drag during the entrance reaches nothing." would be accurate.

dialog.querySelector<HTMLElement>('[data-autofocus]')?.focus();
}
return;
// Under a finger, a tap or a drag during the slide-in reaches nothing.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking: same as in Dialog.tsx, the hold applies under a coarse primary pointer, not under any finger. "Under a coarse primary pointer, a tap or a drag during the slide-in reaches nothing."

});
}

/** Resolves after `ms`. */

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking: this comment restates after(ms). It can go.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

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 on new record text at this head: the added component:MobileNav FR5 sentence ("After the dialog closes, the rest of the slide-out is inert.") and the spec:AST-094 current-state note that, after a finger, an unmoved mouse's first press lands on an entering layer's shield, which departs from FR35's "A mouse keeps today's behavior". The approval given for the previous commit does not carry over to the rebased one, so an engineering owner records the decision by commenting /approve-spec <full head SHA> on this pull request (the author may do so as an engineering owner).

Whatever that decision, one change is needed:

  • packages/core/src/Layer/inputHold.ts:725-732 copies the private deepActiveElement walk from packages/core/src/utils/selectionLock.ts:585-592, so export one shared helper (for example from utils/flatTree.ts, beside containsAlongFlatTree) and use it in both.

Optional, non-blocking:

  • Dialog.tsx:646 and useDrawerDialogPresence.ts:152 say "Under a finger", but the hold runs only under a coarse primary pointer, and the comments at holdEntranceInput.ts:71 and inputHold.ts:349 restate the code beside them.

[Automated review]

Comment thread packages/core/src/Layer/inputHold.ts Outdated
}

/** The focused element, inside any shadow roots on the way down. */
function deepActiveElement(doc: Document): Element | null {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This walk is the same as the private deepActiveElement in packages/core/src/utils/selectionLock.ts (lines 585-592). Export one shared helper (for example from utils/flatTree.ts, beside containsAlongFlatTree and parentAcrossShadow) and call it from both files, so a fix to the walk reaches both users.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done at 09ee02b: utils/flatTree.ts exports deepActiveElement beside containsAlongFlatTree and parentAcrossShadow, and both inputHold.ts and selectionLock.ts import it; flatTree.test.ts covers light DOM, a missing document, and two nested open shadow roots.

Comment thread packages/core/src/Dialog/Dialog.tsx Outdated
focusForLastInput(autofocusTarget);
}
}
// Under a finger, a tap or a drag during the entrance reaches nothing.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking: the hold runs only under a coarse primary pointer, so "Under a coarse primary pointer, a tap or a drag during the entrance reaches nothing." is accurate.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done at 09ee02b: "Under a coarse primary pointer, a tap or a drag during the entrance reaches nothing."

dialog.querySelector<HTMLElement>('[data-autofocus]')?.focus();
}
return;
// Under a finger, a tap or a drag during the slide-in reaches nothing.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking: as in Dialog, "Under a coarse primary pointer" matches what holdEntranceInput does.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done at 09ee02b, with the same wording for the slide-in.

});
}

/** Resolves after `ms`. */

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking: this restates the function's name and signature; it can go.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Removed at 09ee02b.

Comment thread packages/core/src/Layer/inputHold.ts Outdated
return release;
}

/** The pointers whose presses are held: a finger's and a pen's. */

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking: this restates the set below it; it can go.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Removed at 09ee02b.

@cixzhang

cixzhang commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

/approve-spec 09ee02b

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Recommending approval. The input hold does what spec:AST-094 FR35 asks for Dialog, AlertDialog, Drawer and MobileNav under a coarse primary pointer, the record edits carry an engineering owner's approval at this head, and sharing deepActiveElement from utils/flatTree.ts resolves the last requested change.

Nothing else is required. Optional, non-blocking:

  • MobileNav.tsx:524-525: the hold ref is not cleared when the hold ends on its own; keeping the release in a local of the open/close effect fixes the comment and drops three redundant releases.
  • MobileNav.tsx:289-299 and GestureHost.ts:2133: MAX_EXIT_MS also caps the slide-in hold, and holdsTouch also holds during a dismissal, but each comment states only the first half.

[Automated review]

const drawerRef = useRef<HTMLDivElement>(null);
const closeTimeoutRef = useRef<ReturnType<typeof setTimeout>>(null);
// The input hold on the drawer's layer while it slides in under a coarse
// primary pointer. It is null under a fine one and once the slide-in ends.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking: nothing clears this ref when the hold ends on its own, so it is not null once the slide-in ends; the spent release stays until the effect's next run or cleanup. Keeping the release in a local of the open/close effect and releasing it in that effect's cleanup makes this comment unnecessary, along with the releases at lines 591-592, 610-611 and 650-651.

*/
export const MAX_CLOSE_DELAY_MS = 250;
/**
* Longest the drawer slides out for, in ms. The slide-out runs for the

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking: lines 603-607 also use MAX_EXIT_MS to cap the slide-in hold. A name for both slides (for example MAX_SLIDE_MS), or one sentence here, makes that second use visible.

const recognizers: GestureRecognizer[] = [];
let consumer: TouchConsumer | null = null;
let consumerElement: Element | null = null;
// A layer that the input hold holds takes no touch while it presents.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking: holdsTouch also holds a layer while a 'layer' dismissal runs, so "while it presents or dismisses" is accurate.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

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 spec decision at this head: this commit is the previously approved one rebased onto main with only the ci.yml path list changed, so the earlier /approve-spec no longer applies, and two pieces of record text need it again:

  • component:MobileNav FR5's new sentence, "After the dialog closes, the rest of the slide-out is inert.", with its verification row.
  • spec:AST-094's current-state note that, after a finger, an unmoved mouse's first press lands on an entering presentation's shield and does nothing, which departs from FR35's "A mouse keeps today's behavior".

An engineering owner records the decision by commenting /approve-spec <full head SHA> on this pull request (the author may do so as an engineering owner). Nothing else blocks: the hold does what spec:AST-094 FR35 asks for Dialog, AlertDialog, Drawer and MobileNav under a coarse primary pointer, and a mid-entrance tap that closes each of them on main leaves it open here.

Optional, non-blocking:

  • MobileNav.tsx:524-525: the hold ref is not cleared when the hold ends on its own; keeping the release in a local of the open/close effect fixes the comment and drops three redundant releases.
  • MobileNav.tsx:289-298 and GestureHost.ts:2133: MAX_EXIT_MS also caps the slide-in hold, and holdsTouch also holds during a dismissal, but each comment states only the first half.

[Automated review]

@cixzhang

cixzhang commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

/approve-spec c63f2f9

@cixzhang

cixzhang commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

/approve-spec 616c6a2

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approved pending CI: CI has not run on this head yet. The pull request is a draft from a fork, so once it is marked ready for review, a maintainer adds the ci:run label so CI runs.

The input hold does what spec:AST-094 FR35 asks for Dialog, AlertDialog, Drawer and MobileNav under a coarse primary pointer, and the record text it adds (MobileNav FR5's inert slide-out tail and AST-094's recorded gap for an unmoved mouse after a finger) carries an engineering owner's approval for this exact head.

Optional, non-blocking:

  • MobileNav.tsx:524-525: the hold ref is not cleared when the hold ends on its own; keeping the release in a local of the open/close effect fixes the comment and drops three redundant releases.
  • MobileNav.tsx:289-298 and GestureHost.ts:2133: MAX_EXIT_MS also caps the slide-in hold, and holdsTouch also holds during a dismissal, but each comment states only the first half.

[Automated review]

@cixzhang

cixzhang commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

/approve-spec 80ffcbb

@astracat-bot astracat-bot Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Changes requested for one CI defect; the input hold itself and its record text are ready.

  • .github/scripts/ci-interaction-webkit.test.mjs fails 2 of 14 cases at this head on packages/core/src/Layer/inputHold.ts, because the WebKit lane's path-filter line it needs moved to #9906, which is still an open draft: land #9906 first and rebase onto it, or restore those two hunks here.

Everything else matches spec:AST-094 FR35: Dialog (with AlertDialog through it), Drawer and MobileNav hold a finger's tap or drag during their entrance under a coarse primary pointer, with keys, assistive technology and pinch-zoom live, and the record edits carry an engineering owner's approval for this head. Three optional comment fixes raised earlier still apply (MobileNav.tsx:524-525 and :289-299, GestureHost.ts:2133).

[Automated review]

import {containsAlongFlatTree} from '../utils/flatTree';
import {isIosWebKit} from '../utils/touchQueries';
import {type WheelSegment, WheelSegmenter} from './wheelSegmenter';
import {holdsTouch} from '../Layer/inputHold';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This import puts Layer/inputHold.ts into the gesture fixture's bundle, and .github/scripts/ci-interaction-webkit.test.mjs requires every bundled file to be in the WebKit lane's path filter. At this head the filter does not list it, so "keeps everything packages/core/src/gestures/tests/browser/fixture.ts bundles in scope" and "keeps everything the pager bundle bundles in scope" fail (:216). Land #9906 first and rebase onto it, or restore its two hunks here.

…av (spec:AST-094 FR35)

Layer/inputHold holds a layer's pointer input while its presentation
enters or leaves. holdInput(layer, {until, scope}) raises a shield:
'viewport' over the whole viewport while a modal layer enters; 'surface'
over the layer's own box while a non-modal one enters, the page around it
live; 'layer' over the viewport, its content inert, while a layer leaves.
It decides by each event's pointer (a finger's or a pen's press is held to
its end; a mouse passes once it moves with no button down, or from the
start when the page last heard a mouse, DEC-16), never cancels a touch's
start, and cancels a one-finger move so a pinch still zooms (FR10). It
hears input through Core's shared input stream before every other
subscriber, and ends the dispatch for a held event, so press feedback and the
gesture host never hear it; the stream gains first subscribers and records
the type of the latest pointer event it delivers (getLastPointerType). The gesture
host asks the hold for each touch (holdsTouch), so the hold imports nothing
from gestures/ and a component in the root entry may use it statically
(architecture:react-component-runtime INV13).

Its first users hold only under a coarse primary pointer, a touch
presentation (FR13): Dialog, Drawer and MobileNav hold a finger's tap or
drag while they enter (holdEntranceInput, which reads the entrance's own
animations; MobileNav's slide-in is capped at 550 ms), a non-modal Drawer
only on its panel. Under a fine primary pointer nothing is held and
nothing listens (FR24). Where the primary pointer is fine and the screen
also takes touch, a finger is not held yet; AST-094's current-state impact
and Drawer's record name that gap. AST-094's current-state impact also
names the first press of a still mouse after a finger, which lands on an
entering presentation's shield. AlertDialog renders Dialog, so its
entrance is held too: its record says FR7 ships in part, the leaving
alert's inert content waiting for FR6's dismissal.

Every browser proof lands its input at an exact point (an entrance held
where it is, the page's clock, or an exact step of a drag) and plays once.

The WebKit interaction lane's scope includes Layer/inputHold.ts: the
gesture host imports it, so the gesture specs' fixture bundles it.

MobileNav's record states what the rest of the slide-out after the close
does: it is inert, taking no input and hidden from assistive technology
(FR5). A browser case proves both against the open drawer.

From meta#8342 and its per-event offer, meta#8630 (Dialog and Drawer) and meta#8374
(MobileNav).

Co-authored-by: Cindy Zhang <cindyxz@meta.com>
@cixzhang

cixzhang commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

/approve-spec fbe71df

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Changes requested for one CI defect; the input hold itself and its record text, owner-approved at this head, are ready.

  • .github/scripts/ci-interaction-webkit.test.mjs fails 2 of 14 cases at this head on packages/core/src/Layer/inputHold.ts, because the WebKit lane's path-filter line it needs is in #9906, which is still open: land #9906 first and rebase onto it, or restore those two hunks here.

Everything else matches spec:AST-094 FR35: Dialog (with AlertDialog through it), Drawer and MobileNav hold a finger's tap or drag during their entrance under a coarse primary pointer, with keys, assistive technology and pinch-zoom live, and AppShell is left as it is on main. CI has not run its test jobs on this head yet; a maintainer adds the ci:run label so it runs. Optional comment fixes: MobileNav.tsx:520-521, MobileNav.tsx:288-295 (MAX_EXIT_MS now only caps the slide-in hold) and GestureHost.ts:2133.

[Automated review]

* dialog closes, while the dialog's `display` transition keeps it rendered.
* The touch hold on the slide-in ends by this time.
*/
export const MAX_EXIT_MS = 550;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking: with AppShell's use gone, MAX_EXIT_MS is read only as the slide-in hold's cap (line 603), so the name says exit for an entrance cap. A name for what it bounds (for example MAX_SLIDE_MS) makes the comment's second and third sentences unnecessary.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approved pending CI: CI has not run on this head yet, so a maintainer adds the ci:run label for it to run.

The merge of main brings the WebKit lane's path filter for packages/core/src/Layer/inputHold.ts, so .github/scripts/ci-interaction-webkit.test.mjs now passes with the hold in the gesture bundle. The input hold matches spec:AST-094 FR35 for Dialog (and AlertDialog through it), Drawer and MobileNav under a coarse primary pointer, with keys, assistive technology and pinch-zoom live, and the record text it adds is owner-approved at this head.

Optional comment fixes still stand: MobileNav.tsx:520-521 (nothing clears the hold ref when the hold ends on its own), MobileNav.tsx:288-295 (MAX_EXIT_MS now only caps the slide-in hold) and GestureHost.ts:2131 (the hold also holds during a dismissal).

[Automated review]

@cixzhang

cixzhang commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

/approve-spec 72f6726

This branch was successfully deployed

1 active deployment
Preview — 72f6726f Deployed Nov 4, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci:run A maintainer lets this outside contributor's pull request run CI (AST-030 FR17) 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