Skip to content

feat(Resizable): a resizable LayoutPanel slides closed and open, and focus leaves a collapsing panel - #9300

Draft
vjeux wants to merge 22 commits into
meta:mainfrom
vjeux:layout-panel-edge-reveal
Draft

vjeux wants to merge 22 commits into
meta:mainfrom
vjeux:layout-panel-edge-reveal

Conversation

@vjeux

@vjeux vjeux commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

The fourth of five stacked pull requests for createResizable; the extraction (#9197) is merged. It is built on the public export of createResizable (#9372) and merges after it; this branch carries the commits of #9297, #9370 and #9372. This head's own changes: vjeux/astryx@7a6ab57...c5b5bca. The deprecation (#9202) follows it.

User need

A LayoutPanel whose region collapses jumped shut, and focus inside it stayed in a panel of no width. The shared edge reveal that SideNav's rail and Collapsible use (spec:AST-105 FR9) now covers it.

Problem and solution fit

  • The slide (component:Resizable FR8, component:LayoutPanel FR7). A LayoutPanel at a useResizable(handleRef) region's size beside its ResizeHandle, or given the configuration-object hook's own props, slides closed and open from the edge facing its handle. The content moves on the same motion.
    • Every collapse and open plays it: collapse(), expand(), a resize out of collapse, Enter, a double-click, the grow arrow, a controlled isCollapsed change, and a drag past the collapse threshold.
    • A drag that reopens follows the pointer and plays nothing. A hand-built ResizableProps object, which useResizable did not return, does not slide.
  • Focus (component:LayoutPanel AR2). Focus inside a collapsing panel moves to its ResizeHandle. A commit later, the panel goes inert and hidden from assistive technology until it opens. This holds under any resize controller, a hand-built object included.

Current specification

  • spec:AST-105 FR2 and FR9
  • architecture:react-component-runtime INV12
  • family:layout-regions (LayoutPanel lends its element and owns a collapsing panel's focus move and inert state)
  • component:Resizable FR8
  • component:LayoutPanel FR7 and AR2

Public delta and caller burden

No prop, option or return member is added. Two changesets: [feat] the slide, and [fix] the focus move under every controller.

Design and alternatives

  • The pane channel. A LayoutPanel sized by a resize controller lends it its element in its callback ref, through an internal channel, with no prop.
    • A ref-form factory finds the lent panel just before its handle, or just after it when reversed.
    • A LayoutPanel that takes the configuration-object hook's props hands its pane to that hook's region in the same callback ref, not in a layout effect. The factory the region has, now or later, follows it.
    • A hand-built object has no region, so it lends nothing.
  • Measured before the DOM changes. The factory decides each collapse and open as it makes it: a drag that reopens plays nothing, and a key step plays. LayoutPanel then tells the factory of the change in the commit that renders it, before React changes the DOM (LayoutChangeSnapshot), and the pane is measured there. The reveal plays once the pane renders its new size, on the factory's own animated-layout handle over the pane's row.
  • Controlled changes. Both forms report a controlled isCollapsed in the commit of the parent's state (the same-commit rule of refactor(Resizable): one internal createResizable factory, which useResizable's configuration-object form and ResizeHandle's resizable path go through #9297 and feat(Resizable): useResizable(handleRef, options) is createResizable's thin React wrapper, and ResizeHandle renders the factory bound to its element #9370), so the factory hears of it after the DOM has changed. LayoutPanel's snapshot measures it first, the same way.
  • Landing. A resize that neither collapses nor opens, a pointer reopen, the pane leaving, and destroy land a running motion at once.
  • Focus order. LayoutPanel owns the focus move and the inert state. A collapsing panel first moves focus inside it to the handle its controller names, or else the ResizeHandle beside it, through the shared focusForLastInput, so the handle's ring follows the last input (architecture:interaction-modality INV1). A commit later it goes inert and aria-hidden, so inert never drops focus to the page.
  • A handle's own reversal. A ResizeHandle reports its own isReversed after the factory's first commit; the factory then looks for its pane on the other side of the handle and tells it whether the region is collapsed, so an end panel whose region starts collapsed is inert from its first commit.
  • The boundary check (INV12) lists the resize controller's edge reveal (resizablePane.ts) among the animated-layout module's users.

Called out for the spec owners:

  • Two browser fixture stories, kept out of the docs and the visual matrix, render the configuration-object form (the hook's own props, and a hand-built object) for FR8's verification row.

Overlap with an open pull request: #4593 (LayoutPanel hideBelow) also edits LayoutPanel.tsx and LayoutPanel.doc.mjs. This PR adds the pane channel, the change snapshot, and the focus and inert state inside LayoutPanel's body, and one sentence each to its width and resizable entries. Whichever lands second keeps both.

Evidence

Unit tests (the shared edge reveal stood in by a recorder):

  • The slide: each collapse and open is measured at the old width and played at the new one, from the edge facing the handle. A start pane plays from inline-end, an end pane from inline-start. This holds for the ref form and for the hook's own props.
  • Controlled changes: a controlled change is measured before the DOM changes and played once, and every commit shows the parent's state and the hook's together, in both forms.
  • No slide: the reopening drag, a plain resize, the first render, a hook-only pane and a hand-built object play nothing. An unmount lands a running motion.
  • Handoff: a LayoutPanel that replaces another reaches its region through its callback ref, in both forms.
  • Focus: focus moves to the handle, and inert and aria-hidden are set and removed, for the ref form, the hook's props and a hand-built object. A collapse from the handle or from outside the panel keeps focus. After pointer input the move asks for no ring, and after keyboard input it leaves the ring to the browser. An end pane reversed by its ResizeHandle, whose region starts collapsed, is inert from its first commit.
  • Before this change, these tests fail on nothing playing and no focus moving.

Browser ("Core/Resizable / Sliding Start Panel", "Sliding End Panel", "Sliding Controlled Panel", and the two configuration-object fixtures):

  • Every path: collapse() from a button inside the panel and Enter on the handle, a toggle outside the panel, a controlled change, a double-click, the grow arrow, and a drag past the threshold.
    • The pane gets its clip animation and the handle its glide.
    • The handle is on its way at many of the captured moments, and the pane's shown edge stays at its resting offset from the handle at every moment, within 1.5 px.
    • Each motion ends at the layout it lands on, with nothing held.
  • Focus: collapsed from inside, focus is on the handle and the panel is inert and aria-hidden. A toggle outside keeps its focus.
  • No slide: a drag that reopens follows the pointer with no clip or glide. A hand-built object shows neither, and focus still leaves the panel. Under Reduce Motion the handle does not glide.
  • Negative control: without the panel telling its controller of the change before the DOM changes, every slide case fails, and the hand-built and Reduce Motion cases still pass.
  • Capture method: each motion is captured at 24 evenly spaced moments. Every animation the change starts is paused when it is created and held there. Then, in one task, each is set to the same moment and the geometry is read. A renderer that skips frames under load can neither hide a slide nor fake one.
  • The handle's own clock: the handle is placed at the end of the pane's margin box plus its glide's keyframes at the glide's current time. WebKit reports a compositor-run translate ahead of the frame time.

Scope

  • One capability is added.
  • Unrelated fixes, cleanup, and policy changes were removed or split.
  • Consumer docs, focused tests, and migration evidence ship with the feature.
  • Public text and artifacts contain no internal Meta context.

Testing

Local gates at c5b5bca: unit tests (test:ui, 17,496 passed), check:repo, lint, root build then typecheck, the core API report regenerated from this build (it gains only this pull request's rows; on this base, 00146a7, the committed report lacks rows that #9344 later refreshed), the node project (only the known cache.test.mjs red), the docsite build and tests (732 passed), the Storybook build, accessibility in Chromium (77/77), the accessibility audit of Resizable, SideNav and Layout (0 new) and of every story (0 new), interaction Chromium (createResizable, the slide proof and SideNav's rail motion, 21/21), and the browser proofs under stress: Chromium 2-core ×24 264/264, WebKit CI lane (4 CPUs, CI, LP_NUM_THREADS=4, 1 worker) ×10 110/110, WebKit 3-CPU with 1 worker 110/110 and with 16 workers 440/440.

@vercel

vercel Bot commented Nov 1, 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 1, 2026 8:41pm UTC

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Nov 1, 2026
@github-actions github-actions Bot added the needs:spec-owner-review Current knowledge records await owner approval label Nov 1, 2026
@astracat-bot

astracat-bot Bot commented Nov 1, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
❓ Needs maintainer input (for maintainers only) Nov 1, 2026, 8:57 PM UTC

@github-actions

github-actions Bot commented Nov 1, 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

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

The slide and the focus move match the current component:Resizable FR8 and component:LayoutPanel FR7 and AR2 records, but approval waits on one spec decision and three fixes. The head rewords component:Resizable FR3 so that createResizable is the owner ResizeHandle goes through, which no current record states. An engineering owner needs to approve or reject that wording by commenting /approve-spec with the full head SHA; the author may do this if they are an engineering owner. Independently of that decision, please address:

  • An end LayoutPanel reversed by <ResizeHandle isReversed> whose region starts collapsed is neither inert nor aria-hidden until the factory's next update, because the factory looks for the pane before the handle reports its reversal (findPane and configure in createResizable.ts).
  • Move focus with the shared focusForLastInput (utils/focusReturn.ts) instead of a raw .focus() in LayoutPanel.tsx.
  • The diff carries about 8,000 authored lines from three stacked changes; land the lower pull requests first so this one carries only its own commits.

[Automated review]

const binding: ResizeHandleBinding = {
gesture,
read: () => view as ResizeHandleView,
configure(config) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

configure only refreshes the view. The factory's first commit() already ran findPane() with isReversed() false, so an end panel reversed by <ResizeHandle isReversed> is not found, and is not held collapsed or made inert, until the next commit. A region that starts collapsed leaves that panel reachable by Tab (component:LayoutPanel AR2). Re-finding the pane here when reversal changes (for example by calling commit()) fixes it.

const element = elementRef.current;
const active = element?.ownerDocument.activeElement;
if (element != null && active != null && element.contains(active)) {
(paneLink.focusTarget() ?? resizeHandleBeside(element, area))?.focus();

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 programmatic move bypasses focusForLastInput (utils/focusReturn.ts), which owns focus moves under architecture:interaction-modality INV1 and architecture:react-component-runtime INV16. Calling it here keeps this path on the shared pointer handling.

@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 isn't ready to approve: it waits on one spec decision, and three fixes are needed regardless. The head rewords component:Resizable FR3 so that createResizable is the owner ResizeHandle and useResizable go through, which no current record states; an engineering owner needs to approve or reject that wording by commenting /approve-spec with the full head SHA, and the author may do so if they are an engineering owner. Independently of that decision, please address:

  • Keep a region's size and collapse state across an <Activity mode="hidden"> hide and show: both useResizable forms now come back at their defaults because the layout-effect cleanup destroys the factory and the next one starts from the options alone (useResizable.ts), where the base kept both.
  • Replace ResizeHandle's two ref-guarded warning effects with the shared useDevWarning hook.
  • Land #9297 first and retarget so this pull request carries only its own slide and focus commits; the diff is about 7,400 authored lines today.

[Automated review]

});
useIsomorphicLayoutEffect(
() => () => {
slot.get()?.resizable.destroy();

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 cleanup destroys the factory that holds the region's size and collapse state. When <Activity mode="hidden"> hides the tree and shows it again, the create effect above builds a new factory from the options alone, so the region comes back at defaultSize and defaultIsCollapsed; the base's configuration-object form kept both in React state. Carrying readResizableSelection(...) across this cleanup, as the element-swap path does, would keep them.

if (isDisabled || !resizable || !resizable._collapsible) {
return;
}, [direction, regionDirection]);
useEffect(() => {

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 ref-guarded useEffect + devWarn (and the axis warning above it) is what useDevWarning(component, message, condition) in hooks/useDevWarning.ts already does once per mount. Calling the shared hook keeps one path for dev warnings.

vjeux added 2 commits November 1, 2026 08:04
…t (spec:AST-107 FR6)

Red: there is no createResizable yet. The tests drive a plain element
through the factory: its role, name, value and tab stop, a pointer drag
that holds its pointer, the keys, collapse and restore, a controlled
collapse, disabled input, update and subscribe, a percentage basis,
persistence, destroy mid-drag, a region with no handle yet, and an
import that does no DOM work.
…ain element a window splitter that resizes a region, without React (spec:AST-107 FR6)

createResizable(handle, options) gives the element role=separator, a tab
stop and the catalog's English name when it has none of its own, its
orientation and value, and answers a pointer drag that holds its pointer
(so an embedded frame cannot swallow the drag), the arrow keys, Home,
End, and Enter and a double-click to collapse and restore. It resolves,
clamps, snaps and saves through resizableModel.ts, as useResizable does,
reports the size through subscribe and onSizeChange, takes new options
through update, and destroy removes every listener, observer and
attribute it added. With a null handle the region still answers
collapse, expand, resize and subscribe. The input lives in
resizeGesture.ts. Importing either module does no DOM work.

It is not exported from the package, so the changeset records it as
maintenance.
…ions

update() replaces the options, as a new render does, so an option left
out returns to its default; the region keeps its size and collapse
state within the new options. Its JSDoc said the handle and the region
keep their state, which read as a merge. A test pins the contract: a
full set with one change keeps the selection, the name and the bounds,
and a set that leaves options out returns them to their defaults.
vjeux added 2 commits November 1, 2026 09:57
…he region's state has one copy (architecture:public-component-api INV22)

Red: the configuration-object form creates no factory yet. With a
stand-in factory, the tests check that it creates one, with no handle,
from the configuration, passes later options through update(), destroys
it on unmount and returns what it reports, in its size, collapse state
and props. Guards that pass on the current implementation and must keep
passing on the factory: the form reports size and isCollapsed in the
commit of onCollapseChange's own state, controlled or not, through
collapse(), Enter, expand(), double-clicks and a drag past the threshold
(component:Resizable FR8); it keeps its size and collapse state across
an <Activity> that hides and shows it; and a configuration object kept
across renders follows a container swapped behind containerRef.
…and ResizeHandle's `resizable` path goes through its input

The configuration-object form is createResizable with no handle of its
own, so the region's state has one copy, the factory's (spec:AST-107
FR6; architecture:public-component-api INV22). The props it returns
carry what the factory reports, and a ResizeHandle given them drives the
factory's region through its model, read at each input so a whole drag
reaches the factory as it is now. ResizeHandle's input for a
`resizable` prop is the factory's input (resizeGesture.ts) over the
props, so the handle has one implementation of the drag, the keys and
the double-click.

Behavior unchanged: size and isCollapsed land in the commit of
onCollapseChange's own state, a controlled isCollapsed included (FR8);
the region keeps its size across an <Activity> (the hook's store
remembers where it stood and seeds the next factory), and follows a
container swapped behind containerRef with the same configuration.
React's store subscriptions do not read the store again after an
<Activity> shows its subtree, so the hook renders again from the layout
phase when the factory made for the reveal reports something new. The
factory stays internal, so no public API or record changes; the
changeset records the move.

@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 isn't ready to approve: it waits on one spec decision, and several fixes are needed regardless. The head rewords component:Resizable FR3 so that createResizable is the owner ResizeHandle and useResizable go through, which no current record states; an engineering owner needs to approve or reject that wording by commenting /approve-spec with the full head SHA, and the author may do so as an engineering owner. Independently of that decision, please address:

  • Route LayoutPanel's focus move through useFocusLeavingChange and moveFocusLeftBehind, which main now uses for SideNav and Collapsible, instead of a second layout effect.
  • Move focus out of, and make inert, a LayoutPanel sized by width={region.size} from the configuration-object form, which still keeps focus when it collapses (component:LayoutPanel AR2).
  • Replace ResizeHandle's remaining ref-guarded axis warning with useDevWarning.
  • Land the stacked #9297, #9370 and #9372 first and retarget, so this pull request carries only its own commits; the diff is about 7,800 authored lines today.

[Automated review]

if (!isRegionCollapsed && isSettledCollapsed) {
setIsSettledCollapsed(false);
}
useIsomorphicLayoutEffect(() => {

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 effect re-implements the focus-leaving sequence that main now owns in useFocusLeavingChange (utils/useFocusLeavingChange.ts) with noteFocusWithin and moveFocusLeftBehind (utils/focusReturn.ts), which SideNav and Collapsible call from their LayoutChangeSnapshot. architecture:react-component-runtime INV16 asks for one call to the shared owner and no component-level effect for the concern. After rebasing, note focus before the collapse with useFocusLeavingChange, move it with moveFocusLeftBehind(note, paneLink.focusTarget() ?? resizeHandleBeside(panel, area), panel), and keep the settle state only for inert.

paneLink.isCollapsed,
returnFalse,
);
const isFoundBeside = resizable == null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

A panel given width={region.size} from the configuration-object form, beside <ResizeHandle resizable={region.props}> (the shape of the HorizontalSplit story), lends itself here, but no controller ever finds it: the configuration-object factory has no handle, and this panel has no resizable prop. Collapsing it from a button inside leaves focus there, and the panel is neither inert nor aria-hidden, which component:LayoutPanel AR2 asks for under any resize controller. The ResizeHandle given resizable props could tell the panel beside it the region's collapse state for the focus move and inert only, with no slide.

hookDirection !== direction &&
!didWarnAxisRef.current
) {
if (didWarnAxisRef.current) {

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 ref-guarded effect is what useDevWarning(component, message, condition) already does, and the call just below uses it. useDevWarning('ResizeHandle', <the same message>, regionDirection != null && regionDirection !== direction) replaces didWarnAxisRef and the effect.

vjeux added 8 commits November 1, 2026 10:28
…ontainer after an <Activity> hid and showed the region before then (spec:AST-010)

Red: while the container is not laid out, the temporary 1200px basis
stands in, so a '30%' default shows 360px. A factory made for an
<Activity> reveal took that stand-in as the region's selection, kept
360px after the container measured 800px, and saved it, where the
default should resolve to 240px against the measured container and the
temporary basis is never final or saved.
…ry basis's stand-in as the region's size (spec:AST-010)

readResizableSelection reports the size only when it is a selection
(saved, chosen, or the default resolved against a measured basis); while
only the temporary 1200px basis stands in, it reports none, and the hook
seeds the next factory with the collapse state alone, so the factory
resolves its own default once the container measures. This holds for a
factory made after an <Activity> hides and shows the region.
…reateResizable, and ResizeHandle renders the factory bound to its element (spec:AST-107 FR6)

Red: there is no ref-taking useResizable yet. The tests check that it
creates the factory on the element, passes later options through
update(), destroys it on unmount and returns what it reports, in the
commit that onCollapseChange's own state lands in, controlled or not
(component:Resizable FR8); that a ResizeHandle given the ref renders the
region and forwards its drag, keys and double-click to the factory, with
its own isReversed and isDisabled, after a caller's onKeyDown (FR7);
that a handle a child mounts from its own state, or hides and shows in
its own <Activity>, is bound at once, and the region keeps its size
across an <Activity> that hides the hook's own component; that a plain
element drops its listeners when it unmounts and is named and announced
through the message catalog; that the same options follow a container
swapped behind containerRef; and that the hook renders its initial size
on the server.
…s thin React wrapper, and ResizeHandle renders the factory bound to its element (spec:AST-107 FR6)

The hook creates the factory on the element behind the ref as it mounts,
passes later options through update(), destroys it as it unmounts, and
returns what the factory reports, in the commit of the parent's own
state, a controlled isCollapsed included (component:Resizable FR8). The
factory follows the element behind the ref at every commit, and whenever
a ResizeHandle given the ref attaches or lets go of its element, which a
child can do without the hook's component rendering; each new factory
starts where the region stood. A plain element is named and announced
through the provider's catalog messages, which reach the factory through
the package's internal channel.

A ResizeHandle given the ref claims its element, so the factory binds to
it instead of driving it: the handle renders the factory's region and
forwards its pointer, keyboard and double-click events to the factory's
input, after a caller's onKeyDown (FR7), and its own isReversed and
isDisabled apply with the factory's options. A factory stays bound when
the handle lets go of its element and claims it again. The handle
follows the binding from the layout phase, so a factory bound in the
commit that mounts or shows it renders before paint. A ResizeHandle with
a `resizable` prop and a bound region warns once (useDevWarning).
…report rows and its changeset

useResizable's page documents the handle ref passed first, with its
options, and that a ResizeHandle reports its element as it mounts while
a plain element is read when the hook's own component renders.
UseResizableResult is exported, and the core API report gains the
overload and the type (only those rows; on this base the committed
report lacks rows that a later main refreshed).
… drag-across-a-frame proof and API report rows (spec:AST-107 FR6)

createResizable, CreateResizableOptions and ResizableController are
exported from the Resizable entry point and the root, in the pull
request whose FR3 line names createResizable as the owner ResizeHandle
and useResizable go through, so the public API and its record line land
together. createResizable is documented as a hook beside useResizable
(no category, so it is not a tile in the components gallery; the
docsite's route lock names its page, and the doc compiler's pin reads it
by name); its page says update() takes the full set of options, and
useResizable's page names it as the factory both forms wrap. The story
drives a plain divider over an embedded frame, and the browser proof
drags across that frame, which the held pointer keeps from swallowing
the drag. The core API report gains the export rows (only those), and
the changeset announces the factory.
…and useResizable go through

component:Resizable FR3 owned the drag, keys, double-click, snapping,
persistence, reversal and size in ResizeHandle and useResizable. Both
forms of the hook and both paths of the handle go through
createResizable, now public, so FR3 names it as the owner they go
through (architecture:public-component-api INV22).
…asured before the DOM changes, and focus leaves a collapsing pane (component:Resizable FR8; component:LayoutPanel FR7, AR2)

Red: nothing plays and no focus moves. A LayoutPanel at a ref-form
region's size, or given the configuration-object hook's own props, is
measured before the DOM changes and played from the edge facing its
handle once it renders its new size, for every collapse and open
(collapse(), expand(), a resize out of collapse, Enter, a double-click,
the grow arrow, a controlled change, a drag past the threshold); a
controlled change is measured in the commit that renders it, which the
hook reports in the commit of the parent's state. A drag that reopens, a
plain resize and the first render play nothing, an unmount lands a
running motion, and a LayoutPanel that replaces another reaches its
region through its callback ref. A hook-only pane and a hand-built
ResizableProps object show no reveal. Focus inside a collapsing pane
moves to its ResizeHandle and the collapsed pane is inert and
aria-hidden, in every form; a collapse from the handle or from outside
the pane keeps focus.
vjeux added 9 commits November 1, 2026 11:18
…d before the DOM changes, and focus leaves a collapsing pane (component:Resizable FR8; component:LayoutPanel FR7, AR2)

A LayoutPanel sized by a resize controller lends it its element in its
callback ref, through an internal channel (resizablePane.ts), with no
new prop: a ref-form factory finds the lent panel beside its handle,
and the configuration object's region gets the pane its LayoutPanel
hands over, through the hook's own props (a hand-built ResizableProps
object has no region and never slides).

The factory decides each collapse and open as it makes it (a drag that
reopens plays nothing; a key step plays), and LayoutPanel tells it of
the change in the commit that renders it, before React changes the DOM
(LayoutChangeSnapshot), where the pane is measured; it plays spec:AST-105
FR9's edge reveal from the edge facing the handle once the pane renders
its new size, on the factory's own animated-layout handle over the
pane's row. A controlled isCollapsed, which both forms report in the
commit of the parent's state, is measured the same way before the
factory hears of it. A resize that neither collapses nor opens, a pointer
reopen, the pane leaving and destroy land a running motion. The
animated-layout boundary check lists the controller's edge reveal among
the module's users.

Focus inside a collapsing panel moves to its ResizeHandle, then a commit
later the panel goes inert and aria-hidden until it opens, in every form;
LayoutPanel owns both, as family:layout-regions says.
…docs say a pane slides and focus leaves it; changesets for both

LayoutPanel's width and resizable entries, useResizable's handle ref and
createResizable's practices say that a LayoutPanel beside its
ResizeHandle at the region's size slides closed and open, that focus
inside it moves to the handle as it collapses, and that it is inert
while collapsed; the deprecated props give no slide. A [feat] changeset
covers the slide and a [fix] changeset the focus move, in both forms.
…ject does not

LayoutPanel's resizable entry and the [feat] changeset say the
configuration-object hook's own props slide the panel like the ref form
and a hand-built ResizableProps object does not; the [fix] changeset's
focus move and inert state hold for both.
…le on every frame, in Chromium and WebKit (component:Resizable FR8; component:LayoutPanel FR7, AR2)

Two stories render a LayoutPanel at a useResizable(handleRef) region's
size beside its ResizeHandle, at the start and at the end of a Layout.
The browser proof checks the pane, handle and content are present and
painted, then collapses the pane from a button inside it: the handle
travels over several frames, the pane's shown edge stays at its resting
offset from the handle on every frame, the pane ends at its collapsed
layout, focus is on the handle, and the pane is inert and aria-hidden.
Enter on the handle opens it the same way, with no focus move. Under
Reduce Motion nothing travels and focus still leaves the pane. Without
the controller's reveal the slide cases fail.

Each element is read on its own animation's clock: WebKit reports a
compositor-run translate about a millisecond ahead of the frame time
(up to about 1.6 px at the fastest part of a collapse) while the pane's
clip runs on the main thread at the frame time, and both share one start
time and sample grid; in Chromium this equals the rect.

The shipped-axis guard pairs a handle with its region by the ref it
holds, as well as by the resizable prop, so the ref-form stories pair.
…e browser; a hand-built object collapses without a slide (component:Resizable FR8; component:LayoutPanel FR7, AR2)

Two browser fixtures, kept out of the docs and the visual matrix, render
a start LayoutPanel with the configuration-object hook's own props and
with a hand-built copy of them. The first passes the same frame proof as
the ref form; the second collapses and opens with no travelling frame,
and focus still moves to the handle and the panel goes inert.

The proof now places the handle at its layout place, where the pane's
margin box ends in the row, plus its glide's keyframes at the glide's
own time, instead of reading the handle's rect: WebKit reports a
compositor-run translate ahead of the frame time in the rect and the
computed style alike, by up to about 1.8 px. The pane moves by no
transform, and its clip runs on the main thread at the frame time.
…paused motion, so a starved renderer cannot fail it

Under load the proof failed: it sampled the frames the renderer drew,
and with 16 WebKit workers on 3 CPUs (and once with 4 Chromium workers
on 2 cores) it drew 1 to 5 frames over a whole collapse, so its
"a slide, not a jump" check failed although the motion itself runs on
its own clock. The proof now pauses every animation the collapse or
open starts the moment it is created, and holds it there (the module
starts each one on the timeline's current time by setting its start
time, which would set it running), so none advances while the test
waits for a starved page. Then, in one task, it sets each to the same
one of 24 evenly spaced moments before the end and reads the pane and
its handle there, finishes them, and checks what they leave. A slide is
the pane's clip animation and the handle's glide, with the handle on
its way at a third of the moments or more and the pane's shown edge on
the handle at every moment; a hand-built object and Reduce Motion start
no glide. The frame reader is installed once in the page and read the
same way at rest and at every moment.
…led isCollapsed, a double-click, the grow arrow and a drag past the threshold (component:Resizable FR8; component:LayoutPanel FR7)

The sliding stories gain a toggle outside the panel, and a story whose
parent owns isCollapsed through onCollapseChange with its own toggle.
Each path proves the pane clips and the handle glides with the pane's
shown edge on the handle at every captured moment: the toggle (which
keeps its focus while the panel goes inert), a controlled change both
ways, a double-click on the handle, the grow arrow from collapsed, and a
drag past the collapse threshold. A drag that reopens the panel follows
the pointer with no clip and no glide. Without the panel telling its
controller of the change before the DOM changes, every slide case fails
and the hand-built and Reduce Motion cases still pass.
…om its first commit, and focus moves to the handle with the last input's ring (component:LayoutPanel AR2; architecture:interaction-modality INV1)

Red: an end LayoutPanel whose region starts collapsed, beside a
ResizeHandle that reverses it through its own isReversed prop, is
neither inert nor aria-hidden after the first commit, because the
factory looked for its pane before the handle reported its reversal.
And focus moved out of a collapsing panel by a raw focus(), which
shows the ring after pointer input too.
…once, and focus moves to the handle with the last input's ring (component:LayoutPanel AR2; architecture:interaction-modality INV1)

When a ResizeHandle reports its own isReversed after the factory's
first commit, the factory looks for its pane again on the other side of
the handle and tells it whether the region is collapsed, so an end
panel whose region starts collapsed is inert and aria-hidden from its
first commit. LayoutPanel moves focus out of a collapsing panel with the
shared focusForLastInput, and tracks the interaction modality of its
document, so the handle shows its ring after keyboard input and not
after pointer input.

@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 isn't ready to approve: it waits on one spec decision, and several fixes are needed regardless. The head rewords component:Resizable FR3 so that createResizable is the owner ResizeHandle and useResizable go through; an engineering owner needs to approve or reject that wording by commenting /approve-spec <full head SHA> (the author may do so as an engineering owner), or FR3 keeps its current wording here. Independently of that decision:

  • Move LayoutPanel's focus through main's useFocusLeavingChange and moveFocusLeftBehind instead of its own layout effect (LayoutPanel.tsx:302).
  • A LayoutPanel given width={region.size} from the configuration-object form, beside <ResizeHandle resizable={region.props}>, still keeps focus and stays reachable when it collapses, which component:LayoutPanel AR2 covers; fix it or narrow the [fix] changeset.
  • At 7,963 authored lines the diff carries all of #9297, #9370 and #9372; land those first so this pull request carries only its own commits.
  • Replace ResizeHandle's ref-guarded axis-warning effect with useDevWarning (ResizeHandle.tsx:557).

[Automated review]

if (!isRegionCollapsed && isSettledCollapsed) {
setIsSettledCollapsed(false);
}
useIsomorphicLayoutEffect(() => {

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 effect re-implements the focus-leaving sequence main owns in useFocusLeavingChange (utils/useFocusLeavingChange.ts) with noteFocusWithin and moveFocusLeftBehind (utils/focusReturn.ts), which SideNav and Collapsible call from their LayoutChangeSnapshot's onBeforeChange (architecture:react-component-runtime INV16). After rebasing, note focus with useFocusLeavingChange keyed on the region's collapse state and move it with moveFocusLeftBehind(note, paneLink.focusTarget() ?? resizeHandleBeside(panel, area), panel); keep the settle state only for inert.

paneLink.isCollapsed,
returnFalse,
);
const isFoundBeside = resizable == null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

A panel given width={region.size} from the configuration-object form, beside <ResizeHandle resizable={region.props}> (the Collapsible story's shape), lends itself here, but the configuration-object factory has no handle and never looks for it (createResizable.ts:832). It keeps focus and stays reachable when it collapses, which component:LayoutPanel AR2 covers ("the configuration-object form (FR7) included").

// means there is no region axis to compare, not that it is horizontal.
const didWarnAxisRef = useRef(false);
const regionDirection = resizable?._direction ?? view?.direction;
useEffect(() => {

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 ref-guarded effect is what useDevWarning(component, message, condition) already does, and the call just below uses it: useDevWarning('ResizeHandle', <the same message>, regionDirection != null && regionDirection !== direction), deleting didWarnAxisRef and this effect.

This branch was successfully deployed

1 active deployment
Preview — c5b5bca5 Deployed Nov 1, 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:spec-owner-review Current knowledge records await owner approval

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant