Skip to content

feat(AppShell): hiddenRegions hides the top nav or side nav while they stay mounted - #10312

Draft
cixzhang wants to merge 5 commits into
mainfrom
feat/appshell-hidden-regions
Draft

cixzhang wants to merge 5 commits into
mainfrom
feat/appshell-hidden-regions

Conversation

@cixzhang

@cixzhang cixzhang commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

Supersedes #10276 (SideNav collapsedWidth/collapsedVariant), and builds the hide-and-keep part of #3890 by AKnassa and of the Focus Mode spec in #8410 by humbertovirtudes on AppShell. Both are credited as co-authors and in the changeset. Owner decision, Nov 6 2026: hidden regions live on the shell, as one controlled list, so the shell can play the nav's slide and the content's reflow as one motion.

User impact

An app can now hide its top nav, its side nav, or both, for example for a focus mode, and bring them back exactly as they were:

<AppShell hiddenRegions={isFocusMode ? ['topNav', 'sideNav'] : undefined} …>
  • The content takes the freed space. The nav slides out by its edge as the content moves into its place, on one clock, so the content never jumps ahead of or behind the nav.
  • A hidden nav stays mounted: open groups, scroll and SideNav's rail state come back unchanged.
  • A keyboard or screen-reader user can't reach a hidden nav: it is inert. If focus was inside it, focus moves to the main content instead of falling to the page.
  • On a phone, the drawer leaves out each hidden region, and its menu button goes when nothing is left to show. A region that hides while the drawer is open closes it, and revealing the region doesn't reopen it. If the button that opened the drawer is gone, focus goes to main.
  • With the section variant, the side nav's divider goes with it.
  • Apps that don't pass hiddenRegions are unchanged.

Public delta

  • AppShellProps.hiddenRegions?: ReadonlyArray<'topNav' | 'sideNav'>. Exactly those two values; the banner and content can't be hidden. At runtime an unknown entry is ignored with a development warning. No other API change.

Change

  • AppShell.tsx:
    • A hidden side nav keeps its panel mounted at width 0 and inert, without its section divider.
    • A hidden top nav keeps its row in the header at height 0 and inert. With no banner, the header drops its banner landmark.
    • The region's edge and the content's glide play through the shared edge reveal, measured before the commit and played after it, on one clock.
    • Focus inside a hiding region moves to main.
    • On a phone, the drawer toggle and drawer content follow the side nav. The mobile context reports the drawer closed and disabled while it has nothing to show. Hiding never writes mobileNav.isOpen or calls onOpenChange.
  • Phone paths:
    • A hidden side nav stays mounted, hidden and inert, inside the top nav's drawer. The drawer keeps one subtree whichever region hides, so the side nav keeps its state.
    • While a region is hidden, a drawer with nothing left to show reads as closed and disabled, and its menu button goes. What counts: the side nav, the top nav's own items (read from its props during render, with no Effect or state), or the app's own mobileNav.content. A shell that hides nothing keeps its drawer exactly as before.
    • A hidden top nav gives the drawer no heading.
    • TopNav shows its menu button and opens its drawer, during render, only for what is still visible: its own items unless hidden, and the side nav unless hidden. This also covers a TopNav the app wraps in its own component, which AppShell can't read. FR19 states that AppShell counts such a slot as bringing drawer items, so useAppShellMobile() keeps reporting the drawer as enabled there.
    • With only the top nav hidden, the side nav's bar keeps the banner landmark.
  • MobileNav.tsx: an opener that is still connected but hidden (inert) counts as gone, and focus moves to main. Inside AppShell, a menu button removed because the drawer has nothing left to show counts as gone too, and focus moves to this shell's main (an internal context, not the document's first main).
  • Records:
    • AppShell FR19–FR21 (Specs' wording), a Public concepts row, verification rows, Owns and Does-not-own lines, DEC-2 (the spec:AST-002 FR4 admission note and the Implementation note), and the Compatibility line.
    • The MobileNav FR6 amendment, and TopNav FR5 and FR6 clauses deferring to AppShell FR19.
  • Stories Core/AppShell "Focus Mode" and "Hidden Side Nav Section"; docs; [feat] changeset.

Test plan

  • Red first: on main's AppShell, 6 of the 8 new hiddenRegions cases fail; all pass here. The 2 that already pass on main are "focus in content untouched" and "state kept", which main satisfies trivially because nothing hides. The MobileNav hidden-opener case is new.
  • Hides-nothing matrix (phone): hiddenRegions omitted or empty × a heading-only top nav, a top nav with items, app drawer content, and no navigation. It checks the drawer's enabled state and toggle count, and passes on main and here, so the hidden-state logic can't change the default shell.
  • Phone cases, one per path: revealing a region hidden while the drawer was open keeps the drawer closed (uncontrolled and controlled, with onOpenChange never called); a wrapped heading-only top nav with only the side nav hidden shows no menu button; the drawer keeps the hidden side nav's state; a drawer with nothing left reads as closed and its toggle goes; focus reaches main when the drawer closes because its side nav hid; and the banner landmark stays when only the top nav hides.
  • AppShell, MobileNav, SideNav, TopNav and Layout suites: 922 passed, including the 8-case matrix. tsc is clean, and pnpm check:knowledge and pnpm check:changesets pass. Lint and tsc were re-run on this head.
  • Real browser: the exact-head preview check follows. It covers Focus Mode on and off at desktop and phone, mid-slide frames with the nav edge and the content edge together, focus moving to main, and the section divider.

Writing rubric results

R1 narration: none; FR19–FR21, DEC-2 and the MobileNav FR6 amendment state the behavior in the present tense.
R2 research: none in the records.
R3 evidence: FR19–FR21 cite the owner decision and spec:AST-002, spec:AST-105 and MobileNav; verification rows name tests and stories, with no pull request.
R4 rejected alternatives: the Does-not-own list states scope, not history.
R5 change references: none.
R6 open questions: none added.

…y stay mounted

A hidden region takes no space, goes inert and keeps its state. Focus
inside it moves to main. The region's edge and the content's change in
space play as one motion through the shared edge reveal, which measures
both before the commit and plays both on one clock after it. On a phone
the drawer and its toggle follow 'sideNav', and a drawer with nothing to
show reads as closed without writing the app's open state. The section
variant's side nav divider goes with the hidden side nav. MobileNav
moves focus to main when the button that opened it is hidden. Records:
AppShell FR19-FR21, a Public concepts row and verification rows, and the
MobileNav FR6 amendment. Stories: Focus Mode and the section variant.

Co-authored-by: AK <144495202+AKnassa@users.noreply.github.com>
Co-authored-by: Humberto Virtudes <humberto.virtudes@gmail.com>
@vercel

vercel Bot commented Nov 6, 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 6, 2026 10:30pm UTC

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Nov 6, 2026
@github-actions github-actions Bot added the needs:design-review Affects visuals — Design should review label Nov 6, 2026
@astracat-bot

astracat-bot Bot commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Nov 6, 2026, 10:42 PM UTC

@github-actions

github-actions Bot commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

Current run: The current CI run concluded skipped. Current analysis is unavailable.


Generated by PR Enrichment workflow | View current CI run

@cixzhang

cixzhang commented Nov 6, 2026

Copy link
Copy Markdown
Contributor Author

/approve-spec 2733c69

@github-actions github-actions Bot removed the needs:spec-owner-review Current knowledge records await owner approval label Nov 6, 2026

@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. hiddenRegions works as described on desktop, where both regions slide out with the content on one clock and go inert, but four phone paths don't yet match the AppShell records.

  • On a phone, hiding the nav that fills an open drawer removes the menu button instead of hiding it (MobileNavToggle renders nothing once mobile navigation is disabled), so MobileNav's new hidden-opener branch never runs and focus ends in the closed drawer or on the page body instead of main (FR20).
  • On a phone with a top nav, hiding the side nav unmounts its drawer copy and hiding the top nav swaps the whole drawer subtree, so the side nav's state resets after a hide and show (FR19).
  • With a top nav that has no drawer items of its own, hiding the side nav removes the drawer while useAppShellMobile() still reports it open and enabled, so an app's own MobileNavToggle stays expanded for a drawer that is gone (FR19).
  • Hiding only the top nav on a phone leaves the visible side-nav top bar without a banner landmark, because the bar checks headerContent == null while the hidden header still exists (FR3).
  • The changeset's "keep their state" and "MobileNav moves focus to main when the button that opened it is hidden" don't hold inside AppShell on a phone until the points above are fixed.

A phone-width AppShell test for each path (drawer open, then hide) would pin the fixes. The inline comments point at the lines involved.

[Automated review]

Comment thread packages/core/src/AppShell/AppShell.tsx Resolved
Comment thread packages/core/src/AppShell/AppShell.tsx Resolved
Comment thread packages/core/src/AppShell/AppShell.tsx Resolved
Comment thread packages/core/src/AppShell/AppShell.tsx Resolved
Comment thread packages/core/src/AppShell/AppShell.tsx Resolved
…focus to main

- A hidden side nav stays mounted, hidden and inert, inside the top nav's
  drawer, and the drawer no longer swaps its subtree when the top nav
  hides, so the side nav keeps its state on a phone.
- TopNav reports whether it has drawer items of its own, so a drawer
  with only a hidden side nav reads as closed and disabled.
- When the drawer closes because it has nothing left to show, its menu
  button is gone, and MobileNav moves focus to this shell's main.
- With only the top nav hidden, the side nav's bar keeps the banner
  landmark.

Co-authored-by: AK <144495202+AKnassa@users.noreply.github.com>
Co-authored-by: Humberto Virtudes <humberto.virtudes@gmail.com>
@cixzhang

cixzhang commented Nov 6, 2026

Copy link
Copy Markdown
Contributor Author

/approve-spec ab61fbc

@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 phone gaps from the last round are fixed: a hidden side nav keeps its drawer state, an empty drawer reads closed, focus reaches this shell's main, and the banner landmark stays. Four things still need work:

  • With a TopNav that has only a heading and no side nav, a shell that passes no hiddenRegions now treats its drawer as empty, so a mobileNav.content drawer and the app's own MobileNavToggle stop rendering and useAppShellMobile() reports navigation disabled, against AppShell FR11 and FR12 (count content as drawer content and apply the empty-drawer test only while a region is hidden).
  • TopNav reports its items to AppShell from a layout effect into state that starts at true, so the first render, and the server HTML, show a menu toggle for an empty drawer that goes only after hydration; derive whether the top nav has drawer items during render instead.
  • With only the top nav hidden on a phone, the drawer is still headed by the hidden top nav's heading because TopNav passes header={heading} whatever its hidden state, while FR19 keeps a hidden top nav out of the drawer.
  • Miss: hiding both navs also drops a mobileNav.content drawer that still has content to show (FR12), which the first fix covers.

CI has not run on this head yet. The inline comments point at the lines involved.

[Automated review]

Comment thread packages/core/src/AppShell/AppShell.tsx Resolved
Comment thread packages/core/src/AppShell/AppShell.tsx Resolved
Comment thread packages/core/src/TopNav/TopNav.tsx Resolved
Comment thread packages/core/src/TopNav/TopNav.tsx Resolved
…idden

A shell that hid nothing, with a heading-only TopNav and the app's own
drawer content, lost its drawer and toggle. The empty-drawer rule now
applies only while a listed region is hidden, and counts the app's own
drawer content. Whether the top nav has drawer items of its own is read
from its props during render, with no Effect or state, so the server
HTML matches. A hidden top nav gives the drawer no heading. A test
matrix pins the drawer of a shell that hides nothing; it passes on main
too.

Co-authored-by: AK <144495202+AKnassa@users.noreply.github.com>
Co-authored-by: Humberto Virtudes <humberto.virtudes@gmail.com>
@cixzhang

cixzhang commented Nov 6, 2026

Copy link
Copy Markdown
Contributor Author

/approve-spec b8ddd89

@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. hiddenRegions works as described on desktop and leaves shells that hide nothing unchanged, but four things still need work:

  • When an app wraps a heading-only TopNav in its own component and hides only the side nav, topNavHasDrawerItems counts the wrapper as having items, so on a phone the menu button stays and opens an empty drawer while useAppShellMobile() reports it open and enabled, against AppShell FR19.
  • Miss: when a region hides while the phone drawer is open, the drawer closes as FR20 says, but revealing the region reopens it and moves focus into it because AppShell keeps its stored open state, against FR20's "Revealing a region does not move focus"; clear AppShell's own open state when the drawer empties and say in the record what a controlled drawer does on reveal.
  • Miss: TopNav FR5 and FR6 still say the drawer is headed by heading, shows a divider whenever items and side nav content are both present, and keeps the toggle while AppShell passes side nav content, which no longer holds for hidden regions, so add a clause deferring to AppShell FR19 and give the MobileNav FR6 amendment the removed-opener case the code handles.
  • Miss: the changeset, the prop's JSDoc and both doc descriptions say the phone drawer and its toggle follow 'sideNav', but with a top nav that has items hiding the side nav keeps the toggle and with only a top nav hiding 'topNav' removes it, so describe it as the drawer leaving out each hidden region and its toggle going when nothing is left.

CI has not run on this head yet. The inline comments point at the lines involved.

[Automated review]

Comment thread packages/core/src/TopNav/TopNav.tsx Resolved
A TopNav wrapped in the app's own component is not one AppShell can
read, so with only the side nav hidden its menu button stayed and opened
an empty drawer. TopNav now shows its menu button and opens its drawer,
during render, only for its own items unless hidden and the side nav
unless hidden. A drawer with nothing visible closes, and focus goes to
main. FR19 states what AppShell reports for a slot that is not a TopNav
element.

Co-authored-by: AK <144495202+AKnassa@users.noreply.github.com>
Co-authored-by: Humberto Virtudes <humberto.virtudes@gmail.com>
@github-actions github-actions Bot added the needs:spec-owner-review Current knowledge records await owner approval label Nov 6, 2026
@cixzhang

cixzhang commented Nov 6, 2026

Copy link
Copy Markdown
Contributor Author

/approve-spec b5f3bab

@github-actions github-actions Bot removed the needs:spec-owner-review Current knowledge records await owner approval label Nov 6, 2026

@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 wrapped TopNav menu button from the last round is fixed, and shells that hide nothing still render exactly as on main, but three things still need work:

  • When a region hides while the phone drawer is open, revealing it reopens the drawer and moves focus into it because AppShell keeps its stored open state (AppShell.tsx:727-728), against FR20's "Revealing a region does not move focus", and with a heading-only TopNav wrapped in the app's own component the same state leaves useAppShellMobile() reporting the drawer open (an app's own MobileNavToggle reads expanded) while TopNav holds it closed, against FR19; clear the stored state when the drawer empties and say in FR19 and FR20 what a controlled drawer and a top nav slot AppShell cannot read report.
  • TopNav FR5 and FR6 still say the drawer is headed by heading, shows a divider whenever items and side nav content are both present, and keeps the toggle while AppShell passes side nav content, which no longer holds for hidden regions, so add a clause deferring to AppShell FR19 and give the MobileNav FR6 amendment the removed-opener case the code handles.
  • The changeset, the prop's JSDoc and both doc descriptions say the phone drawer and its toggle follow 'sideNav', but with a top nav that has items hiding the side nav keeps the toggle and with only a top nav hiding 'topNav' removes it, so describe it as the drawer leaving out each hidden region and its toggle going when nothing is left.

CI has not run on this head yet. The inline comments point at the lines involved.

[Automated review]

Comment thread packages/core/src/AppShell/AppShell.tsx Resolved
Comment thread packages/core/src/TopNav/TopNav.tsx Resolved
Comment thread .changeset/appshell-hidden-regions.md Resolved
…d revealing it does not reopen it

The drawer's stored open state survived a hide, so revealing the region
reopened the drawer and moved focus into it. A region that hides while
the drawer is open now holds the drawer shut until the app opens it
again; the app's own open state is not written, and useAppShellMobile()
reports the drawer closed meanwhile. Records: AppShell FR19 and FR20,
TopNav FR5 and FR6, and MobileNav FR6 state the hidden-region cases.
The changeset and docs say the drawer leaves out each hidden region.

Co-authored-by: AK <144495202+AKnassa@users.noreply.github.com>
Co-authored-by: Humberto Virtudes <humberto.virtudes@gmail.com>
@github-actions github-actions Bot added the needs:spec-owner-review Current knowledge records await owner approval label Nov 6, 2026
@cixzhang

cixzhang commented Nov 6, 2026

Copy link
Copy Markdown
Contributor Author

/approve-spec cc51d1c

@github-actions github-actions Bot removed the needs:spec-owner-review Current knowledge records await owner approval label Nov 6, 2026

@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 three changes requested last round are in: revealing a hidden region now keeps the phone drawer closed until the menu button opens it again, TopNav FR5/FR6 and MobileNav FR6 state the hidden-region cases, and the changeset and docs describe the drawer as it ships. Shells that don't pass hiddenRegions still render exactly as on main.

Two optional follow-ups, both inline:

  • Miss: a standalone MobileNav whose hidden opener sits beside a plain <main> (no tabindex) leaves focus on the page body, because that <main> can't take focus, so FR6's "focus moves to main" holds only inside AppShell or for a focusable main.
  • The new drawer-hold comment in AppShell.tsx could be split into short, active sentences.

[Automated review]

Comment on lines +406 to +408
shellFocus?.main() ??
opener.ownerDocument.querySelector<HTMLElement>('[role="main"], main')
)?.focus();

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.

Miss, non-blocking: outside AppShell this focuses the document's first main. A plain <main> without tabindex is not focusable, so focus() does nothing and focus ends on the page body, not main as the amended FR6 says ("a connected but hidden opener counts as gone, and focus moves to main"). Inside AppShell main has tabIndex={-1}, so that path works. Scoping the FR6 sentence to a main that can take focus, or giving main tabindex="-1" for the move, closes the gap.

Comment on lines +730 to +733
// A region that hides while the drawer is open closes the drawer, and the
// drawer stays shut until the app opens it again, so revealing the region
// never reopens it. The app's own open state is not written. The hold is
// adjusted during render from what this render sees.

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 (comment style): the first sentence runs 30 words over several topics, and "The hold is adjusted during render" is passive. Suggested:

// A region that hides while the drawer is open closes the drawer.
// The drawer stays shut until the app opens it again. Revealing the
// region does not reopen it. Hiding never writes the app's open state.

This branch was successfully deployed

1 active deployment
Preview — cc51d1cc Deployed Nov 6, 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.

1 participant