Skip to content

fix(MobileNav): closing runs like a UIKit dismissal - #8064

Closed
vjeux wants to merge 1 commit into
meta:mainfrom
vjeux:uikit/c14-mobilenav-close
Closed

vjeux wants to merge 1 commit into
meta:mainfrom
vjeux:uikit/c14-mobilenav-close

Conversation

@vjeux

@vjeux vjeux commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

User impact

Closing the phone navigation drawer let input through before the drawer was gone. For the first 60% of the slide-out, the leaving drawer's links stayed live. After that, taps reached the page while the drawer was still visibly leaving. Focus returned mid-slide, and inside AppShell the drawer was unmounted 300 ms into its 410 ms slide-out.

Evidence

iOS 26.2 simulator, real taps (Chromium where noted):

Path Clip
Close: the scrim fades out with the drawer close
Tap a link in the leaving drawer: nothing tap on the leaving drawer
Tap the menu button mid-slide-out: swallowed tap on the page
Inside AppShell: the whole slide-out AppShell
Reduce Motion (Chromium): closes at once reduce motion

On main these look the same; the difference is when input returns, which the contract asserts. Focus return is test-only.

  • Before: the new Chromium contract fails 3 of 4 tests on main (not modal midway, a link tap on the leaving drawer registers, a late tap reaches the page); on the simulator the dialog closes about 238 ms after the touch.
  • After: Chromium 4/4, three runs in a row; on iOS it closes at the slide-out's end (about 363 ms), and a tap 230–290 ms in lands only on the backdrop. Evidence README.

Problem and solution fit

The close was timed to a CSS display hold, not the slide-out. UIKit (iOS 26.2) drops taps during a sheet dismissal; input returns when it completes.

The dialog now stays open, modal and rendered until the slide-out ends. The drawer panel is inert meanwhile (not the dialog: an inert modal dialog stops blocking the page in Chromium). When the slide-out ends, the dialog closes, the scroll lock is released and focus returns to the opener.

Expected behavior and authority

  • family:overlay-dismissal (current): members own focus (AV1), modality (AV2) and presentation (AV6). Escape and close requests are unchanged.

Smallest restoration

  • MobileNav: the exit timer is the drawer's computed transition, capped at MAX_EXIT_MS (550 ms; 0 under Reduce Motion); the display transition is removed ([Bug] MobileNav close freezes the page in Safari 26.1 #4290).
  • AppShell: the Activity hold is MAX_EXIT_MS + 50 ms (was 300).
  • No public API or motion change.

Owner decision (proposed; pending owner review): input stays blocked for the whole slide-out, and focus returns when it ends, as in UIKit.

Siblings: #8042, #8046, #8057 (ci.yml); #7356, #7358, #8063 (MobileNav.tsx); whichever lands second rebases.

Scope

  • One defect is restored.
  • Separable contradictory or unsettled tagalongs were removed or split.
  • No new public API, visual representation, default, or interaction model is hidden under the bug-fix label.
  • Public text and artifacts contain no internal Meta context.

Testing

  • pnpm exec vitest run packages/core/src/MobileNav packages/core/src/AppShell packages/core/src/SideNav packages/core/src/TopNav packages/core/src/Layer: 699 passed.
  • pnpm -F @astryxdesign/core typecheck; eslint and prettier on touched files; pnpm check:repo (pre-commit).
  • pnpm exec playwright test packages/core/src/MobileNav/__tests__/MobileNavClose.a11y.chromium.spec.ts --project chromium: 4 passed; against main, 3 failed and 1 passed.
  • iOS 26.2 simulator (iPhone 17 Pro), Safari, built Storybook: two runs per side.
  • CI: a new "Run MobileNav close contract" step, after the Table min-width contract.

The drawer closed its modal dialog 60% of the way into the slide-out
(at most 250 ms in), which cut the scrim's fade off partway, left the
leaving drawer's links live until then, and, inside AppShell, let the
Activity boundary cut the slide-out off at 300 ms.

The dialog now stays open, modal and rendered until the slide-out ends,
and the drawer panel is inert meanwhile: nothing takes a tap during the
slide-out, as in a UIKit dismissal (taps 0.1 s and 0.25 s into an iOS 26.2
sheet dismissal are dropped). When it ends, the dialog closes, the scroll
lock is released and focus returns to the opener. The scrim fades over the
whole slide-out, and AppShell holds its Activity boundary for MobileNav's
longest slide-out (MAX_EXIT_MS) plus a buffer.
@vercel

vercel Bot commented Oct 24, 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 Oct 24, 2026 4:14am UTC

Request Review

@github-actions github-actions Bot added the needs:design-review Affects visuals — Design should review label Oct 24, 2026
@astracat-bot

astracat-bot Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
❓ Needs maintainer input (for maintainers only) Oct 24, 2026, 4:49 AM UTC

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Oct 24, 2026
@github-actions

Copy link
Copy Markdown
Contributor

PR Analysis Report

📚 Storybook Preview

View Storybook for this PR

🧪 Sandbox Preview

View Sandbox for this PR

Modified Components

AppShell (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Bundle Size (ESM) N/A N/A N/A
Lines of Code N/A 608 -
Complexity N/A Very High (108) -

Bundle Size Summary

Package Size (ESM) Size (CJS) Gzipped
@astryxdesign/core N/A 4.9KB 1.2KB

Accessibility Audit

Status: No accessibility violations detected.

Visual Regression

46 of 494 shot(s) changed.

A change here is a question, not a failure: check whether the after is the
picture you intended. Record that review on the PR. Baseline maintenance is an
explicit dispatch of CI; this report never rewrites the baseline or adds a release gate.

component story theme mode pixels
Selector Indicator Space Bottom Sheet Narrow End RTL neutral light 753,933
Selector Indicator Space Bottom Sheet Wide Start neutral light 740,072
Chat Full AI Chat probe light 230,823
Chat Full AI Chat probe dark 229,805
RichTextEditor Markdown parity: side by side probe dark 94,866
RichTextEditor Markdown parity: side by side probe light 89,762
Collapsible Dividers probe dark 66,445
Collapsible Dividers probe light 66,445
Chat Default neutral dark 36,304
Chat Default neutral light 35,767
Chat Empty State probe light 23,531
Chat Empty State probe dark 22,622
ChatAutoScroll Scroll Behavior Comparison neutral dark 15,378
ChatAutoScroll Scroll Behavior Comparison neutral light 14,107
Chat With Attachments probe light 13,406
Chat With Attachments probe dark 11,232
ProgressBar With Target Mark probe dark 7,895
ProgressBar With Target Mark probe light 7,895
Markdown Default neutral dark 7,573
Markdown Default neutral light 7,566

and 26 more.


Generated by PR Enrichment workflow | Storybook | Sandbox | View full report

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

This pull request is on hold for an owner decision: no current spec decides how MobileNav treats input and focus while it closes, so blocking every tap until the slide-out ends and returning focus only at its end needs an engineering owner to approve or reject that behavior, or to point to the spec that governs it. An engineering owner records approval by commenting /approve-spec <full head SHA> on this pull request, and as an engineering owner you may record it yourself. Independently, one fix is needed: when the dialog is closed natively (a <form method="dialog"> submit or ref.current.close()) before isOpen turns false, the effect's already-closed branch in MobileNav.tsx never releases the scroll lock, so overflow: clip and the scrollbar gutter stay on <html>; calling releaseScrollLock() there, with a regression test, restores the cleanup main has.

[Automated review]

setIsCloseHeld(false);
// Nothing is open, so there is no slide-out to hold the dialog for, and
// no exit timer is left to release the hold.
setIsExiting(false);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

When the dialog was already closed natively (a <form method="dialog"> submit, or ref.current.close() through the forwarded ref) before isOpen turned false, this branch is the only one that runs, and nothing releases the scroll lock any more: the effect cleanup no longer resets overflow or the gutter. A jsdom probe (open, native close(), isOpen={false}, 2 s) leaves overflow: clip and scrollbar-gutter: stable on <html> here and clears both on the base. Calling releaseScrollLock() in this branch restores that cleanup (architecture:react-component-runtime INV3).

@cixzhang

Copy link
Copy Markdown
Contributor

Posted by Cindy's agent on her behalf.

Parts of this PR re-implement behavior that already exists, or is being specified, as a shared part of core. Converging keeps one implementation per behavior:

This is about where the behavior lives, not about the PR's intent.

@vjeux

vjeux commented Oct 24, 2026

Copy link
Copy Markdown
Contributor Author

Agreed: one implementation.

cixzhang pushed a commit that referenced this pull request Oct 25, 2026
…8063)

## User impact

On a phone narrower than the drawer, `MobileNav` covered the whole screen: the default 320 px drawer on a 320 px phone left no backdrop to tap, although the `width` prop promises "Capped at 85vw to prevent overflow on small screens". The page the drawer navigates was hidden entirely.

## Problem and solution fit

The drawer's width was `width: 100vw; max-width: <width>px`, which is min(100vw, width): the only cap was the viewport itself. A drawer that covers the screen hides its context and takes away the usual way out, a tap beside it; UIKit's overlay sidebar never covers the whole screen. Small phones, zoomed-in text and split-screen windows reach these widths.

The fix swaps the two: `width: <width>px; max-width: 85vw`, which is min(width, 85vw). On a 320 px phone the drawer is 272 px wide, and a 48 px strip of the dimmed page stays beside it as a target for the tap that closes it.

## Expected behavior and authority

- `MobileNav.doc.mjs`, `width`: "Drawer width in pixels. Capped at 85vw to prevent overflow on small screens."

## Smallest restoration

One style function in `MobileNav.tsx`. No API, token or default changes. From 377 px up, 85vw is at least 320 px, so the default drawer is unchanged there.

Siblings: #8042, #8046 and #8057 also add `ci.yml` steps, and #7358, #7356 and the close-path fix #8064 touch `MobileNav.tsx`; whichever lands second rebases.

## Evidence

Before (`main`) and after, 320 × 568, drawer open:

![320 px before](https://raw.githubusercontent.com/vjeux/astryx/ac3890212337192ec30b259a1b507ad3144c4194/320-before.png)
![320 px after](https://raw.githubusercontent.com/vjeux/astryx/ac3890212337192ec30b259a1b507ad3144c4194/320-after.png)

iOS 26.2 simulator, real taps (402 px: the drawer keeps 320 px): a tap on the strip closes it:

![tap on the strip closes the drawer](https://raw.githubusercontent.com/vjeux/astryx/e56f122761d3eb0ccf94a2a94c461e263bf1dc59/8063-strip-tap-closes.gif)

- Reproduction before the change: on a 320 px phone the drawer is 320 px wide. The new Chromium contract fails against a `main` Storybook (expected 272, received 320).
- Result after the change: a 272 px drawer and a 48 px strip; the strip is backdrop (`elementFromPoint` is the dialog), and a real tap on it closes the drawer.
- Representative unchanged path: on a 402 px phone the drawer keeps its 320 px width and the 82 px strip.
- Regression test or other mutation-sensitive proof: `MobileNavWidth.a11y.chromium.spec.ts` (real Chromium, touch), plus a jsdom test that the drawer's own class declares `max-width: 85vw` and takes its width from the prop.

## Scope

- [x] One defect is restored.
- [x] Separable contradictory or unsettled tagalongs were removed or split.
- [x] No new public API, visual representation, default, or interaction model is hidden under the bug-fix label.
- [x] Public text and artifacts contain no internal Meta context.

## Testing

- `pnpm exec vitest run packages/core/src/MobileNav packages/core/src/AppShell`: 136 passed.
- `pnpm -F @astryxdesign/core typecheck`; eslint and prettier on touched files; `pnpm check:repo` (pre-commit).
- `pnpm exec playwright test packages/core/src/MobileNav/__tests__/MobileNavWidth.a11y.chromium.spec.ts --project chromium` against a fresh Storybook build: 2 passed. Against `main`: the 320 px test fails and the 402 px test passes.
- CI: a new "Run MobileNav width contract" step in the a11y contract job, after the Token remove target contract.
@vjeux
vjeux deleted the uikit/c14-mobilenav-close branch October 25, 2026 02:57
@cixzhang

Copy link
Copy Markdown
Contributor

Posted by Cindy's agent on her behalf.

Under the desktop rule (#8347, spec:AST-017 FR55/DEC-24), touch work changes desktop or fine-pointer behavior only to fix a defect desktop users hit, or for deliberate cross-device consistency with desktop before/after evidence. This PR's desktop change is neither, so please make it touch-only and leave desktop unchanged.

This branch was successfully deployed

1 active deployment
Preview — 96537701 Deployed Oct 24, 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