Skip to content

feat(Resizable): a pull past a limit rubber-bands and only the lift closes - #10292

Open
vjeux wants to merge 68 commits into
meta:mainfrom
vjeux:resizable/c20-rubber-band
Open

vjeux wants to merge 68 commits into
meta:mainfrom
vjeux:resizable/c20-rubber-band

Conversation

@vjeux

@vjeux vjeux commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

Stack: #9989 → #10292 → #10333

Behavior module: the resize controller's limits and lift.

User need

A pull past a resizable LayoutPanel's limit stops dead, reading as a bug; crossing the collapse threshold closes it mid-drag.

Problem and solution fit

  • Past either limit, for every pointer, the handle bands at most about 8 px over the content; panes keep their sizes; the lift springs it back.
  • Only the lift closes: below collapsedSize, or a flick resting below it; other lifts return to the minimum.
  • A re-grab catches the handle where it shows; mid-close, the finger moves the paused slide.
  • A hook-size panel and a key stop at the limit; Reduce Motion lands at once.

Try it: #10333 (stacked): 81e's grip band, snaps settling on lift, drag-to-open feedback.

Current specification

component:Resizable FR8, component:LayoutPanel FR7, spec:AST-105 FR4, FR9 and DEC-2, and spec:AST-094 FR45.

Authority: Owner direction, Nov 5 2026 (Cindy): "rubber band at the limits on both sides, so hitting a limit doesn't read as a bug"; Owner direction, Nov 6 2026 (Cindy): "the extra length of the rubber band should ideally not affect the widths of the content but be kind of treated as just extending the handle and overlapping a bit"; Owner direction, Nov 6 2026 (Cindy): "rubber-band travel for a panel resize is at most about 8 px; the panel closes only on release"; Specs decision, Nov 5 2026: snap-to-close decided on lift, with resistance below the minimum; Specs decisions, Nov 6 2026: the band is every pointer's; an arrow key at a limit doesn't band; "A gesture's release settles on spec:AST-094's spring from its release velocity; curve tokens govern glides that no gesture releases." For every pointer: Cindy's decision on #8783, Oct 31 2026, verbatim: "edge reveal A".

Public delta and caller burden

No prop, option or export; size, persistence and ARIA keep to the limits. Resizable's entry gains the velocity tracker (+1,653 B). Changeset: [feat].

Design and alternatives

  • The handle alone draws the overshoot, on its shared animator; releases play springs.settle.
  • Rejected: laying out past a limit; stretching the pane; closing mid-drag.

Evidence

  • Past the maximum (Chromium): the panel stays 320 px, the handle 7.2 px past (before: 391 px).

Writing rubric results

Over component:Resizable (FR8; the Collapse row), spec:AST-105 (FR4/DEC-2 carve-out; FR9; two rows) and spec:AST-094 (FR45's exception). R3: rows name tests. R1, R2, R4–R6: none.

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

On #9989's head fa3bfea (main 3bd058c): #9989's 13 rubber-band and lift-close commits from before the split, cards 81c and 81d, the owner's handle-only overshoot, FR8's records, the re-grab hold mid-close, the closing slide from the drawn handle, AST-105's carve-out, AST-094's matching exception, and two CI fixes. No try-it commit is in this history.

  • Local checks at this head: core typecheck; the Resizable, Layout and motion units (1,326 passed); check:knowledge; check:changesets; check:motion-roles; the Storybook build; paneSlide in Chromium (28) and in WebKit under the CI profile (28).
  • Red first: card 81c's cases on feat(Resizable): a resizable LayoutPanel slides closed and open under every pointer #9989's earlier build (laid out 385 px, 14.5 px of overlap, 94 px); the three re-grab cases on card 81c's build (320, 320 and 292 px); a catch between snap points rounding to a snap (150 px, not 180–190 px); a catch while the panel slides shut drawing the pane 44 px under the content; an arrow key at the maximum drawing the band; the handle-only case on the stretch build (the pane's drawn edge moved 8.09 px); a re-grab mid-close on f78b423's build (the handle 94 px past the finger; a lift above the threshold resting 57.8 px off the pane's edge); a closing lift from the band on 1b58c8d's build (the handle hopping 5.07 and 5.05 px back toward open).
  • Past the maximum, the pane's drawn edge and the content's start stay within 0.5 px, and the handle passes over the content by at most 8 px.
  • A catch with the closing slide held 120 ms in, never finished: the handle stays within 1 px of the finger, the pane's edge and the content keep their places beside it, and past the minimum the pane opens at the finger's size with no motion left. A lift under the threshold shuts it; above it, it opens to the minimum.
  • CI fixes: the touch-support bundle test lists Resizable among the imports that use a gesture, for INV13's listed velocity-tracking gap (the entry carries gestures/VelocityTracker.ts, +1,653 B); it was red with Resizable unlisted. Prettier re-pads the Resizable spec's three tables (public concepts, behavioral and layout contract, verification map): whitespace only, no cell text changes.
  • A closing lift from the band: no frame moves the handle back toward open, at either side. A close a controlled owner keeps open springs the drawn handle back by the next frame.
  • On an iPhone (iOS 26.2 Simulator), at the previous head f78b423: the slide past the maximum and under the minimum, a close on the lift and a reopening drag.

vjeux added 27 commits November 6, 2026 02:00
Cases for a LayoutPanel that takes a useResizable region's own props: it
is measured before the collapse commits and plays from the edge facing
its ResizeHandle, on every collapse and open, a controlled isCollapsed
and a drag past the threshold, with nothing for a drag that reopens it,
the first render or a plain resize. A hand-built object, a pane at the
hook's size alone, and a LayoutPanel at the hook's size beside a handle
given the props show no slide.

Two cases for the shared edge reveal: a conceal keeps the padding and
overflow the element was laid out with and puts back the element's own
values once the motion ends, and it keeps the scroll offset, which a
reveal that reverses it scrolls back to. These cases fail until the next
commit.
…ery pointer

A LayoutPanel given a useResizable region's own props hands its element
to the region, and the region's internal factory plays `spec:AST-105`
FR9's edge reveal as it collapses or opens, under every pointer (FR5).
It measures in the commit that renders the change, before the DOM
changes, and plays once the pane renders its new size. A pointer drag
that reopens the pane follows the pointer instead, which the
ResizeHandle given the props reports to the region. The slide picks no
timing of its own: it follows the shared animated-layout module. No
prop, option, export or type changes.

The panel lends its element from a layout Effect instead of its callback
ref, both to the region and to a ResizeHandle that finds it beside it.
At width 0 the panel tells the scroll-region hook it does not scroll,
which changes that hook's ref. React then runs every merged ref again,
so a lend in the callback ref was taken back and given again
mid-collapse, which landed the slide before it played.

While the shared edge reveal holds a concealing element at the extent it
was laid out at, it now also keeps the element's padding, overflow and
scroll offset as laid out, and puts back the element's own values once
the motion ends. A LayoutPanel at width 0 drops its padding and stops
scrolling in the collapsing commit, so without that its children jumped
by the padding and a scrolled panel jumped to its top as the slide
began. A reveal that reverses a conceal scrolls back to where the
conceal kept it.
…nd open

The `resizable` description, in English, Chinese and dense form, says
that a panel given the hook's own value slides closed and open from the
edge facing its ResizeHandle, and that a hand-built object does not. A
`[feat]` changeset describes the slide.
…le in Chromium and WebKit

Real-browser proof on new stories: a start and an end panel given the
hook's value slide closed and open with the content beside them, through
a toggle, a controlled isCollapsed, a double-click, the grow arrow and
Enter. A drag reopens a pane under the pointer, and a hand-built object
collapses without a slide. Focus moves to the handle as a pane
collapses. The slide proof captures its moments by seeking the paused
motion.

Nothing inside a sliding pane moves, and a scrolled pane keeps its
scroll offset as it slides closed. The pane's shown edge stays where the
motion leaves it beside the handle: a panel without a divider overlaps
its handle by its collapse spacing only while open.
… list the slide as missing

The implementation gaps for the pane slide (Resizable FR8, LayoutPanel
FR7) close, and the current-state notes drop them. FR8 and FR7 keep their
cell widths, so no other row re-pads: each gap sentence becomes a sentence
of the same length, which says that a panel at the hook's size beside the
handle does not slide. The verification rows name the paths the browser
spec drives and the unit cases for the rest.
…takes the handle

At every captured moment of a closing and an opening slide, on both
sides, a press at the handle's middle where it shows must land on the
handle. A drag that starts there 40 ms into a closing slide, with the
motion held, must take the handle and reopen the pane. Both fail until
the next commit: the gliding content beside the handle takes the press.
A slide translates the handle and the content beside it. A translate
makes the handle a stacking context, which traps the grab zone's own
z-index inside it, and the content, later in the document and
translated too, stacked above the zone. So a finger that landed on the
handle mid-slide pressed the content, the browser took the pan, and the
drag never started. The handle now carries z-index 2 itself, as an
overlay handle already does. At rest nothing changes: 22 stories with
handles give identical screenshots and hit tests.
…nd the lift decides a close

Past the maximum, and under the minimum, the pane follows the pointer
with resistance and springs back to the limit after the lift. A drag
past the collapse threshold closes only on its lift, and a flick toward
closing closes from above it; anything else returns to the minimum. The
close plays the settle spring from the release velocity. The unit cases
and the paneSlide cases fail until the next commit: today the edge
stops at each limit, and the pane collapses mid-drag.
…nd the lift decides a close

Past the maximum, and under the minimum, the pane follows the pointer
through core's shared rubber band, over the region's basis, and the
reported size runs past the limit by what the band shows. The resize
gesture now hands the lift's velocity to the region. After the lift the
size springs back to the limit on the settle spring from that velocity,
and a new press catches it where it shows. A drag no longer collapses
the pane mid-way: the lift closes it when the pointer is past the
collapse threshold, or released fast enough to come to rest past it,
and the close slides on the settle spring from the release velocity.
The slide takes that spring through a release glide the layout
animation now accepts for one frame. Reduce Motion still lands both at
once.
…ose decided on the lift

FR8 adds the pull past either limit, through core's shared rubber band,
its spring back on springs.settle from the release velocity, and the
close a lift makes past collapsedSize, or a flick whose projected rest
is past it, on the same spring. A drag no longer collapses mid-way, and
a press on the handle mid-slide takes the handle. The collapse state
row, its verification row, the collapsedSize docs and the hook's
callback note say the lift decides. A changeset records it.
…size runs past one

While a pull rubber-bands past a limit, the region reports the size it
shows, past the bound. The separator's aria-valuenow must stay at the
bound. This case fails until the next commit.
…ize runs past one

aria-valuenow is the size clamped to the separator's bounds, so a pull
that rubber-bands past a limit announces the limit, as before. The
collapsed case already used the minimum, and keeps it.
A per-frame trace after the lift records the pane's laid-out width and
the handle's drawn middle. The pane must keep the width it was pulled
to until it takes the maximum, once, while the edge passes the places
between. The spring-back stepped from script lays out 30 widths, so
this case fails until the next commit.
The spring-back no longer steps the pane's size from script each frame.
It commits the bound once, and the pane's edge reveal plays the change
on the settle spring from the release velocity, as the close does. A
conceal holds the pane at the width it was pulled to, clips it and
rides the content beside it; a reveal below the minimum opens to it.
A finger that catches it mid-glide starts from the size it shows.
FR8 has a press on the Handle where it shows mid-slide take the Handle.
On iOS WebKit that press lands on content until animated-layout glides
resolve their hit position, so Current-state impact names the gap.
A grab on the Handle where it shows mid-slide takes it on iOS WebKit
too: the grab zone stays above the gliding content. So Current-state
impact no longer lists the gap.
…'s tempo

At tempo 1.5 a lift's close and spring-back must take 1.5 times as
long as the settle spring. This case fails until the next commit.
…s tempo

The close and the spring-back after a lift resolve `springs.settle` for
the row, so a theme's tempo times them like every other spring role.
A panel sized by the region's `size`, with no pane lent to the region,
has no rubber band: it stops at the maximum and does not jump back on
the lift. This case fails until the next commit.
The overshoot past a limit reaches the region's size only while a
LayoutPanel lends its pane, whose edge springs back on the shared glide.
A panel at the hook's size alone stops at the limit, as before, so
nothing jumps back on the lift. The pointer's raw size is now
`pointerSize`.
…zableProps

`ResizableProps._onResizeEnd` keeps main's signature. The hook keeps a
module-private map from its props to an end that takes the release
velocity, and the ResizeHandle ends a drag through `endResizeAt`, which
the package does not export. A hand-built props object ends without a
velocity.
The rubber band and its spring-back play when a LayoutPanel renders the
pane. A pane rendered at the hook's size alone stops at the limit, under
AST-094 FR5. The changeset says the same.
The `@output` headers of `resizablePane.ts`,
`animatedLayout.boundary.test.ts` and `animatedLayout.edge.test.ts`, and
`resolveMotion`'s description, ran past INV29's sentence length. Each
now says one thing per sentence.
… no reflow

Each frame of a pull past either limit and its spring-back, the pane
keeps its laid-out width, its button keeps its size, and the edge stands
at most 8 px past the limit. The content beside never overlaps the
drawn pane. These cases fail until the next commit: the pane is laid out
71 px past the maximum, and its button jumps 359 -> 288 px at the end.
…eflow

The overshoot past a limit no longer reaches the region's size. The
pane keeps its laid-out size, and its edge is drawn past the limit: the
pane stretches from its far edge on the compositor, and the handle and
the content beside it move with the edge. The rubber band's extent is
8 px, so the edge moves at most that far. After the lift the drawing
springs back on the settle spring from the release velocity, with no
layout. A finger catches it where it shows.
…t closes

FR8 records the cap and that the pane's content keeps its size past a
limit, and that only the lift closes the pane. The reported size keeps
to the limits. The changeset says the same.
`captureMotion` and `holdMotionAt` gave a change 1 s to start its
motion, then decided nothing animates. Under load a commit can land
later. They now wait, frame by frame, until a motion starts or the
handle says the collapse state changed, then two more frames for a
later commit.
@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 7, 2026 1:12pm 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
@astracat-bot

astracat-bot Bot commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

AI review status for this pull request.

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

@cixzhang
cixzhang marked this pull request as ready for review November 7, 2026 01:34
@cixzhang
cixzhang marked this pull request as draft November 7, 2026 01:58
@cixzhang
cixzhang marked this pull request as ready for review November 7, 2026 01:58
github-actions Bot added a commit that referenced this pull request Nov 7, 2026
vjeux added 2 commits November 6, 2026 18:16
The Resizable entry now carries the gesture system's velocity tracker
(1,653 B, via ResizeHandle and the resize gesture): the lift's close and
the handle's spring-back start from the release velocity. INV13 lists
velocity tracking among the gaps that components reach directly, so the
bundle test lists Resizable among the imports that use a gesture, with
that reason. It failed with the Resizable entry unlisted.
FR8's longer cells left three tables in the Resizable spec unpadded: the
public concepts, the behavioral and layout contract, and the
verification map. Prettier re-pads them; no cell text changes.
@vjeux
vjeux marked this pull request as draft November 7, 2026 02:23
@github-actions github-actions Bot added the needs:spec-owner-review Current knowledge records await owner approval label Nov 7, 2026
@cixzhang

cixzhang commented Nov 7, 2026

Copy link
Copy Markdown
Contributor

/approve-spec 94cdeb8

@github-actions github-actions Bot removed the needs:spec-owner-review Current knowledge records await owner approval label Nov 7, 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 because the pull request is a draft; it runs once the pull request is marked ready for review.

An engineering owner approved the spec changes at this head, and no blocking findings remain. The diff includes #9989's slide, and #10333 is built on this pull request's previous head, so restack it to keep the stack's size exemption.

Non-blocking:

  • Miss: the public collapsedSize JSDoc (useResizable.ts:72) still says "dragging triggers collapse", while only a drag's lift collapses at this head; "Size in px under which a drag's lift collapses the region." matches the docs this pull request already updated.
  • Still open from the last review: the comment at separatorAttributes.ts:58 describes a size past the bound, which no longer happens now that only the handle is drawn past a limit; "The value stays inside the bounds." says what the clamp does.
  • The earlier note about the release close's linear() easing is withdrawn: the slide's keyframes are sampled per frame, so it stays on the compositor.

[Automated review]

@vjeux
vjeux marked this pull request as ready for review November 7, 2026 03:08
github-actions Bot added a commit that referenced this pull request Nov 7, 2026
@github-actions github-actions Bot added the needs:spec-owner-review Current knowledge records await owner approval label Nov 7, 2026
@cixzhang

cixzhang commented Nov 7, 2026

Copy link
Copy Markdown
Contributor

/approve-spec c4a095b

@github-actions github-actions Bot removed the needs:spec-owner-review Current knowledge records await owner approval label Nov 7, 2026
github-actions Bot added a commit that referenced this pull request Nov 7, 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. A pull past a resizable LayoutPanel's limit now bands at most about 8 px and springs back, a collapsible region closes only on the lift, and the panel slides closed and open. An engineering owner approved every rewritten record at this head, and CI passes.

This head only merges #9989's refreshed branch; this pull request's own changes are the same as at the last review, and #9989's slide is part of this diff.

Non-blocking, still open:

  • The collapsedSize JSDoc in useResizable.ts still says "dragging triggers collapse"; only the lift collapses now ("Size in px under which a drag's lift collapses the region").
  • The comment above aria-valuenow in separatorAttributes.ts says a pull past a limit shows a size beyond it, but the value stays clamped ("The value stays inside the bounds").

#10333 still builds on this pull request's previous head, so the declared stack lines up again once it is rebased.

[Automated review]

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

cixzhang commented Nov 7, 2026

Copy link
Copy Markdown
Contributor

/approve-spec 20b42fa

@github-actions github-actions Bot removed the needs:spec-owner-review Current knowledge records await owner approval label Nov 7, 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.

This pull request is reviewed as part of its stack; see the review on #10333.

This branch was successfully deployed

1 active deployment
Preview — 20b42fab Deployed Nov 7, 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