Skip to content

feat(Resizable): createResizable drives a plain handle element, with one separator derivation - #9718

Draft
vjeux wants to merge 28 commits into
meta:mainfrom
vjeux:resizable-factory-plain-handle
Draft

vjeux wants to merge 28 commits into
meta:mainfrom
vjeux:resizable-factory-plain-handle

Conversation

@vjeux

@vjeux vjeux commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #9297 and #9529, whose commits this branch shows until they merge. Its own commits: vjeux/astryx@555b70d...6a1ed94. #9370's ref form calls this path.

Maintainer impact

spec:AST-107 FR6 has each interaction hook be its factory's thin React wrapper, so the factory must drive a handle element. This gives the internal factory that path and one separator derivation shared with ResizeHandle, split from #9370.

Intended invariant

No public API or visual change; the factory stays internal until #9372 exports it. One behavior changes, a [fix] that restores component:Resizable FR3 (spec:AST-085 FR1): a disabled ResizeHandle keeps its tab stop, so keyboard users can reach it and hear that it is disabled.

Change

  • The factory on a handle. createResizable(handle, options) takes an element, or null for a region with no handle. On an element it adds the separator's role, tab stop, name, orientation and value, and the input of resizeGesture.ts; destroy removes them. The options add label, collapsedLabel, isReversed and isDisabled, with shipped English as the default text.
  • One derivation. separatorAttributes.ts derives the separator's ARIA and tab stop. ResizeHandle renders it and the factory writes it, so a disabled handle keeps its tab stop in both.
  • The configuration-object form creates its factory with a null handle.
  • Housekeeping. [chore] and [fix] changesets. INV3a's evidence gap narrows to a labels key or a flat option under a provider, and component:Resizable drops the closed tab-stop gap.

Evidence

  • Failure before the change: the test commits fail (36 failures: no handle argument, no separatorAttributes; 3 failures: a disabled handle left the tab order).
  • Success after the change: the factory's plain-element tests pass. They cover the separator, an element's own role, name and tab stop, a captured pointer drag (an iframe's included), bounds, snap points, right-to-left, reversal, keys, Enter, double-click, disabled input, saving, and a clean destroy.
  • Product/runtime behavior verified unchanged: ResizeHandle and the factory announce the same attributes, enabled, disabled and collapsed. The Resizable, Layout, SideNav and AppShell suites pass.

Scope

  • No intended public API, visual direction, or policy change. The behavior change is the one [fix] above.
  • Product changes discovered during the work were removed or split.
  • Public text and artifacts contain no internal Meta context.

Writing rubric results

Over architecture:internationalization (INV3a's evidence gap) and component:Resizable (the closed tab-stop gap, in FR3, its verification row, the Current-state line, the state row and the relation). The records are owner-gated, so they wait for a spec owner's /approve-spec.

  • R1 narration: none.
  • R2 research: none.
  • R3 evidence: the row names tests, not changes.
  • R4 rejected alternatives: none touched.
  • R5 change references: none.
  • R6 open questions: none.

Testing

Local checks at 6a1ed94: core typecheck, the Resizable, Layout, SideNav and AppShell units (801 passed), strict eslint and prettier on the changed files, and the comment-length check. The test commits fail before their changes (36 failures, then 3). The full gates run at this head once CodeBunny approves.

@vercel

vercel Bot commented Nov 3, 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 3, 2026 11:02am UTC

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Nov 3, 2026
@astracat-bot

astracat-bot Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
❓ Needs maintainer input (for maintainers only) Nov 3, 2026, 11:23 AM UTC

@github-actions github-actions Bot added the needs:design-review Affects visuals — Design should review label Nov 3, 2026
@github-actions github-actions Bot added the needs:spec-owner-review Current knowledge records await owner approval label Nov 3, 2026
@github-actions

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

This review needs an engineering owner's decision, and it also asks for changes that stand whatever that decision is.

Owner decision needed. This branch carries the Grab zone changes from #9529. The zone stays centred on the divider whatever pillPlacement says, it narrows to half of a short pane, and the Grip pill now starts a drag. The Resizable component record states only a --spacing-4 Grab zone strip. An engineering owner (the author qualifies) needs to name the current clause that governs these behaviors, or approve them as an amendment to that record.

Required changes:

  • Land #9680, #9529 and #9297 first and rebase: today the diff carries 5,269 authored lines from four pull requests, and only 1,018 of them are this pull request's own commits.
  • Keep a disabled factory handle's tab stop: separatorAttributes returns tabIndex: -1 while disabled, but the Resizable record (FR3) and the factory's own isDisabled doc say a disabled handle keeps its tab stop.
  • Keep the provider case in INV3a's evidence gap: the new factory tests cover a flat option only outside a provider, so the gap should read "no factory test covers a labels key, or a flat option under a provider, yet".

The factory path, the shared separator derivation and the carried hydration and mid-drag fixes look right. The Resizable, Layout, SideNav and AppShell suites and the core typecheck pass at this head.

[Automated review]

: undefined,
'aria-valuetext': value?.isCollapsed ? value.collapsedText : undefined,
'aria-disabled': state.isDisabled || undefined,
tabIndex: state.isDisabled ? -1 : 0,

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 disabled separator leaves the tab order here, and the factory writes this on a plain element. The Resizable component record's FR3 says a disabled Handle keeps its tab stop, and so does the factory's own isDisabled doc. Return 0 here (and update FR3's ResizeHandle gap note and the parity tests), or keep the tab stop in the factory.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 5f4a410: separatorAttributes keeps tabIndex: 0 while disabled, so a disabled ResizeHandle and a factory's plain element both keep their tab stop, as FR3 says. The record drops its gap lines, and a [fix] changeset records it. The tests fail before the fix.

| INV2, INV13, INV14 | export drift checks, public i18n tests, compatibility review | A parallel/replacement runtime lands without migration, or client hooks claim server behavior |
| INV3–INV5 | resolver and provider rerender tests | no-provider output depends on the host, locale swaps stay stale, fallback order changes, or malformed-locale behavior is described as graceful |
| INV3a | factory unit tests: a flat option or `labels` key replaces its message, including under a provider, and outside a provider its absence announces or shows shipped English; wrapper tests: under a non-English provider its absence gives the translation. Evidence gap: no factory test covers a flat option or `labels` key yet | a factory announces or shows Astryx text that no option or `labels` key can replace, groups more than one string outside one `labels` object, takes a translator, ignores a given string, or gives something other than the catalog message when it is absent |
| INV3a | factory unit tests: a flat option or `labels` key replaces its message, including under a provider, and outside a provider its absence announces or shows shipped English; wrapper tests: under a non-English provider its absence gives the translation. Evidence gap: no factory test covers a `labels` key yet | a factory announces or shows Astryx text that no option or `labels` key can replace, groups more than one string outside one `labels` object, takes a translator, ignores a given string, or gives something other than the catalog message when it is absent |

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 new factory tests cover a flat option only outside a provider, and this row asks for the case under a provider too. Suggest: "Evidence gap: no factory test covers a labels key, or a flat option under a provider, yet."

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Taken in 5f4a410: "no factory test covers a labels key, or a flat option under a provider, yet".

@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 needs an engineering owner's spec decision, and it also needs changes that stand whatever that decision is.

Owner decision needed. The branch carries the Grab zone changes from #9529 and writes them into the Resizable record's FR1: the zone stays centred on the divider whatever pillPlacement says, shrinks so a short pane keeps half of itself, and the Grip pill takes the pointer. The current FR1 states only a --spacing-4 strip. The same FR1 text was approved on #9529's head, but that approval does not carry over to this head. An engineering owner records the decision by commenting /approve-spec with this pull request's full head SHA (the author may, as an engineering owner), or #9529 lands first. The decision should also square the shrunk zone and an offset pill with the bidirectionality record's DR8, which keeps a pointer target centred on the visible control at its required size.

Required changes:

  • Land #9680, #9529 and #9297 first and rebase: the diff carries 5,290 authored lines from four pull requests, and only 1,037 of them are this pull request's own commits.
  • Delete FR3's "Implementation gap: a disabled Handle sets tabIndex={-1}" sentence and the matching verification-row gap in the Resizable record, which this pull request's own fix makes false, and reword the disabled state's summary in ResizeHandle.a11y.states.ts.

The factory now keeps a disabled handle's tab stop and INV3a's evidence gap names the provider case, as asked last time. The Resizable, Layout and SideNav suites and the core typecheck pass at this head.

[Automated review]

| FR1 | ResizeHandle renders one focusable Handle and one enlarged Grab zone (a `--spacing-4` strip on the resize axis); the default render also contains a Grip pill, and caller `children` replace the pill. | Current source, docs, and tests |
| FR1 | ResizeHandle renders one focusable Handle and one enlarged Grab zone (a `--spacing-4` strip on the resize axis); the default render also contains a Grip pill, and caller `children` replace the pill. The grab zone is centred on the Handle for every `pillPlacement`, and the painted pill is its own grab target. On a pane too short for the full strip, the zone shrinks so the pane keeps at least half of itself. | Current source, docs, tests, and an owner ruling |
| FR2 | The Handle carries `resize-handle`, the Grip pill carries `resize-handle-pill`, and the Grab zone carries no public target. | Current source and docs |
| FR3 | Pointer drag, keyboard, double-click collapse, snapping, persistence, reversal, and size behavior are owned by ResizeHandle and useResizable; dragging under right-to-left inverts the pointer delta so the divider follows the pointer. A disabled Handle ignores pointer and keyboard input and exposes `aria-disabled`, and keeps its tab stop (`spec:AST-085` FR1). With `autoSaveId`, storage holds the last settled size and receives the final size once when a drag ends, cancels, or unmounts. Implementation gap: a disabled Handle sets `tabIndex={-1}` (Current-state impact). | Current source, docs, and tests |

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 FR3 sentence still says a disabled Handle sets tabIndex={-1} and points at a Current-state entry this pull request deletes, while the code now keeps tabIndex 0. Drop "Implementation gap: a disabled Handle sets tabIndex={-1} (Current-state impact)."

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Removed in 1aca7db.

| FR1 | `ResizeHandle.test.tsx`; stable visual baseline `core-resizable--horizontal-split` | Default, collapsed, both axes | Handle and Grab zone regressions fail focused tests; a changed default handle comes back changed for review. Gap: no test renders `children` to prove they replace the Grip pill. | `audit:Resizable/anatomy` |
| FR1 | `ResizeHandle.test.tsx`; `grabZone.interaction.browser.spec.ts` for the zone's place and its short-pane limit; stable visual baseline `core-resizable--horizontal-split` | Default, collapsed, both axes | Handle and Grab zone regressions fail focused tests; a changed default handle comes back changed for review. Gap: no test renders `children` to prove they replace the Grip pill. | `audit:Resizable/anatomy` |
| FR2 | `themingTargets.test.ts`; source review | Handle, Grip pill, Grab zone | A removed target fails the inventory. `themingTargets.test.ts` catches only a renamed or undocumented target; no check fails if the Handle or Grip pill stops carrying its target. | `audit:Resizable/theming` |
| FR3 | `ResizeHandle.test.tsx` and `useResizable.test.ts`; storage timing: `ResizeHandle.test.tsx` ("saves the size once, when a drag ends on …", "saves the dragged size once when the split unmounts mid-drag", "still saves each keyboard step at once") and `useResizable.test.ts` ("writes the size a drag reached once, after … releases it", "writes the final size once when the release comes before the last move renders", "keeps the size a region was dragged to when it unmounts mid-drag", "writes only the re-clamped size when the container shrank mid-drag") | Pointer, keyboard, snap, collapse, persistence; RTL drag; disabled keyboard | A changed transformation, an RTL drag that moves against the pointer, keyboard input that resizes a disabled Handle, or a per-move, stale or missing final storage write fails. Gap: no test covers disabled pointer input or the disabled tab stop. | `audit:Resizable/behavior` |

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

ResizeHandle.test.tsx ("ignores keyboard input when disabled, and keeps its tab stop") now covers the disabled tab stop. End this gap at "no test covers disabled pointer input".

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Narrowed in 1aca7db: the gap now ends at disabled pointer input.

return direction === 'vertical' ? window.innerHeight : window.innerWidth;
}

/** A warning already given, so a resolution repeated silently stays quiet. */

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Non-blocking (INV29): this comment is vague. Suggest: "Starts as warned. The factory warns; this first-render read does not."

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Taken in 1aca7db, slightly reworded: "Starts as warned: the factory warns, and this first-render read does not."

vjeux added 11 commits November 2, 2026 23:15
…ment says, and a short item keeps half of itself for its own drag (component:Resizable FR1)

A browser fixture story draws a calendar column at 48px an hour with a
30-minute and a 15-minute event, each moved by a drag on its body and
resized by the ResizeHandle on its bottom edge. Red before the fix:
- with pillPlacement auto, start or end, the Grab zone's centre sits
  5.5 to 8.5px off the Handle's, because it follows the pill;
- the zone covers 13.5px of the 24px event with the pill on the event's
  side, and 8px of the 15-minute event's 12px even when centred, so the
  events' own drag misses points of their upper half;
- the same holds for position="overlay".
A drag on the 30-minute event's body moves it, and a drag on its
Handle's line resizes it.
…ent says, and takes at most half of a short pane (component:Resizable FR1)

FR1 puts the Grab zone, a --spacing-4 strip on the resize axis, on the
Handle; pillPlacement only places the pill. The zone followed the pill
instead, shifting up to a strip's width toward it, so on a short item
(a 30-minute calendar event resized from its edge) it took most of the
item and stole its own drag.

The zone now sits centred on the Handle for every pillPlacement. On a
pane shorter than the zone, it takes at most half the pane, as
spec:AST-094 FR42's grown targets stop at half the gap: an inline
Handle's zone is at most the pane's length, so half of it reaches into
the pane, and an overlay Handle takes at most half of the pane it sits
in, with the zone kept inside it. A collapsed pane keeps the whole zone,
which reopens it.

The unit tests that pinned the zone to the pill now pin it to the
Handle, with one for the short-pane limit and the collapsed pane; the
docs say pillPlacement moves only the pill, and a [fix] changeset
records it.
…Grab zone

A region at 0px that is not collapsed gets a zero-length inline zone
(min(var(--spacing-4), 0px)) and an overlay Handle clamped to half of
nothing, so no drag can reopen it. Both cases fail here.
…rab zone

A pane with no length has nothing to protect, so it is treated like a
collapsed one: the inline zone and the overlay Handle keep their full
length, and a drag can reopen the pane. The duplicate comment at the
zone's call site goes.
With the Grab zone centred on the Handle, a pill that pillPlacement
draws outside the zone takes no pointer: on an overlay Handle the start
pill sits before the Handle's box, so a drag on it moves a 30-minute
event instead of resizing it, and an inline end pill on a short pane
hangs past the zone's end. The browser spec drags the pill's centre for
every pillPlacement and both positions, and the unit test starts a drag
from the pill. The upper-half check now skips the pill's own box.
The pill is the Grip, so it stays grabbable wherever pillPlacement
paints it: while it is painted, it takes the same pointer handlers as
the Grab zone, with the resize cursor, and a hidden or disabled pill
takes none. The Grab zone keeps its short-pane limit, and pillPlacement
still moves only the pill. The docs and the [fix] changeset say so, and
the changeset now says that an overlay Handle takes at most half of the
pane it sits in.
Each comment sentence the Grab zone change adds has at most 20 words,
and the test comment cites no record. ResizeHandle's header drops the
paragraph on the pill's dynamic offset, which now sits on that style.
No code change.
…s and how it shrinks on a short pane

FR1 named the Grab zone only as a --spacing-4 strip. It now adds the
owner's ruling, word for word: the grab zone is centred on the Handle for
every pillPlacement, and the painted pill is its own grab target; on a
pane too short for the full strip, the zone shrinks so the pane keeps at
least half of itself. FR1's basis names the owner ruling, and its
verification row names the browser spec that checks the zone's place and
its short-pane limit.
…e it starts, and a restating comment goes

A drag that starts on the Grip pill takes capture on the pill, not on the
grab zone, so the header names the element the drag starts on. The
comment over the overlay zone's width restated its min(…, 100%).
… reachable from the Grab zone

An overlay Handle can draw its pill outside the Grab zone: at start in
left-to-right, and at end in right-to-left. A pill that shows only on
hover then hides as the pointer leaves the zone to reach it, and a drag
from where it showed does nothing. A story puts a hover-revealed pill
beside an overlay Handle, and the browser spec crosses from the zone to
the pill and drags it, in both directions.

They fail here: the size stays 250 after the drag.
An overlay Handle can draw its pill outside the Grab zone, so the
pointer leaves the zone to reach it. The hover now stays for 150 ms
after the pointer leaves the zone or the pill, and entering either one
keeps it. So a hover-revealed pill stays shown and takes the pointer
while the pointer crosses to it. The pill's offset functions take
-1 | 1.
@vjeux
vjeux force-pushed the resizable-factory-plain-handle branch from 5f4a410 to 1aca7db Compare November 3, 2026 09:32
@cixzhang

cixzhang commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

/approve-spec 1aca7db

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

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

Changes requested: two changes stand, and the spec question is settled.

  • Land #9529 and #9297 first and rebase: the diff carries 4,602 authored lines, and only 1,045 of them are this pull request's own commits.
  • Add a one-line note to the new hover-grace Effect in ResizeHandle.tsx naming what it synchronizes (the pill's hover timer, cleared on unmount), as the React component runtime record's INV3 requires of every new Effect.

The Resizable record's FR1 Grab zone amendment, with the 150 ms pill hover grace, is approved at this head, and FR3's stale tab-stop gap is fixed. The Resizable, Layout, SideNav and AppShell suites and the core typecheck pass at this head.

[Automated review]

const handleRef = useRef<HTMLDivElement>(null);
const [isHovered, setIsHovered] = useState(false);
const hoverExitRef = useRef<ReturnType<typeof setTimeout> | null>(null);
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 new Effect names no external system, where architecture:react-component-runtime INV3 asks every new Effect to. It is needed (nothing else runs at unmount to clear the 150 ms timer onPointerLeave starts), so one line above it is enough, for example:

// The pill's hover grace timer: cleared on unmount, so it sets no state after.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The timer and its Effect are gone: #9529's f9a1753, which this carries, keeps the pill reachable by its geometry, with an invisible extension across the gap to the zone.

}
}

// --- The handle's attributes ---

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Non-blocking (INV29): this divider, and the ones at lines 766 and 910, restate the code under them. Drop them.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Dropped: the State changes divider in 555b70d (#9297's), the listeners divider in 6a1ed94, and the attributes divider with the rebase.

vjeux added 17 commits November 3, 2026 01:59
…rests between the Grab zone and the pill

The browser case now rests 300 ms in the gap between the zone and the
pill before it reaches the pill and drags. A pointer that pauses on the
way must not lose the pill, so the pill has to stay reachable by its
geometry, not by a grace time.

It fails here: the 150 ms hover grace runs out during the rest, and the
pill hides (opacity 0).
…rab zone, with no hover timer

The 150 ms hover grace was a second hover-timing implementation, which
architecture:interaction-modality INV11 keeps in the shared hover
recognizer. A pointer that rested in the gap also lost the pill. The
pill shown on hover now carries an invisible extension along the resize
axis that spans the --spacing-1 gap to the zone. It takes the pointer
only while the pill does. The timer and its unmount Effect go. The
header keeps its tags and the capture sentence, and the short-pane rule
is stated once, where the pane length is read.
…ct, the configuration-object useResizable builds on it, and a server-rendered region hydrates with the server markup

- createResizable: collapse, expand and resize on request, a gesture
  through the region's model as a ResizeHandle drives it, bounds and snap
  points, a percentage against its container, saving once per gesture,
  update() with the full set of options, subscribe() and destroy, and an
  import that does no DOM work. They fail here: the module does not exist.
- With a stand-in factory, useResizable(config) creates one, from the
  configuration, passes later options through update(), destroys it on
  unmount and returns what it reports. They fail here.
- A region with an autoSaveId whose size is saved hydrates over server
  markup with the default, and a '30%' default with no container hydrates
  over markup the server resolved at its 1200px basis. Both should
  hydrate cleanly, then take the client's size. Both fail here: the first
  logs a hydration mismatch and keeps 200, the second keeps 360.
- Guards that pass here and after: the configuration-object form reports
  in the commit of onCollapseChange, controlled or not; it keeps its size
  and collapse state across an <Activity>; collapse(), expand() and
  resize() apply before its first layout effect and while hidden; its
  warnings start useResizable: in the words they always used; it follows
  a container swapped behind its ref; and a '30%' default resolves against
  its container once measured, after an <Activity> hid and showed it.
…and the configuration-object useResizable builds on it

createResizable, internal to the package, keeps a resizable region with
no React and no element: it resolves, clamps, snaps, collapses and saves
the size through the model in resizableModel.ts, follows a percentage's
basis, and reports through subscribe and onSizeChange. ResizeHandle's
resizable path drives it through resizeGesture.ts's model.

The configuration-object useResizable is that region in React, with one
factory, so the region's state has one copy. Its props carry what the
factory reports, and its signature, props, warnings and one-render-pass
mounts are unchanged. While its component is unmounted or an <Activity>
hides it, the factory lets go of the page and keeps the region, so
collapse(), expand() and resize() apply and report whenever they are
called. Its server snapshot is the state the server renders, so a
hydrating render matches the server's markup, and a saved size or a
percentage default resolved against the window applies in the next
commit.

The comment-provenance baseline drops the useResizable.ts sites this
removes. A [chore] changeset records the move, and two [fix] changesets
record the hydration behaviors.
On an <Activity> reveal, follow() resumes the same factory; none is made
for the reveal. The comment now says so.
…s the factory again after another one

A caller that switches between two stable configuration objects, WIDE,
then NARROW, then WIDE, must get WIDE's bounds back.

It fails here: the hook compares each configuration with the one it
created the factory with, so the last switch never reaches update().
…e factory has now

The hook kept the options it created the factory with and compared every
later configuration against them. So WIDE, then NARROW, then WIDE skipped
the last update() and kept NARROW's bounds. The slot entry now holds the
options last passed to the factory, at creation or at an update.
…one separator derivation

- createResizable on a plain element: the separator it makes, keeping a
  role, name and tab stop the element has, label and collapsedLabel, a
  pointer drag that holds its pointer (an iframe's element included),
  bounds and snap points, right-to-left and a reversed handle, the keys,
  Enter and a double-click, disabled input that leaves the tab order,
  saving through a drag, and a destroy that leaves nothing behind.
- separatorAttributes: an open, collapsed, disabled and unbounded region,
  and no region.
- ResizeHandle and a factory driving a plain element announce the same
  separator attributes, enabled, disabled and collapsed.
- The region's tests and the configuration-object form's stand-in take
  the factory's handle argument, null for a region with no handle.

They fail here: createResizable takes no handle, and separatorAttributes
does not exist.
…esizeHandle and the factory derive the separator's attributes once

createResizable(handle, options) takes a handle element, or null for a
region with no handle yet. On an element it gives role="separator", a
tab stop and a name when the element has none, its orientation and
value, and the window splitter's input from resizeGesture.ts: a pointer
drag that holds its pointer, the keys, Enter and a double-click. Its
options add label, collapsedLabel, isReversed and isDisabled, and destroy
takes all of it away. Its name and collapsed text are the shipped
English messages, and collapsedLabel replaces the collapsed one.

separatorAttributes derives the separator's ARIA and tab stop once.
ResizeHandle renders it and the factory writes it on a plain element, so
a disabled handle leaves the tab order in both. The configuration-object
useResizable creates its factory with no handle. The factory stays
internal, with a [chore] changeset.
…rrows to the labels key

A factory test now covers a flat option: the factory announces
collapsedLabel in place of the catalog's "Collapsed" on a plain handle.
The gap that remains is a labels key.
…e and on a factory's plain element

component:Resizable FR3 keeps a disabled Handle's tab stop, as
spec:AST-085 FR1 does for every disabled control, so keyboard users can
reach it and hear that it is disabled. ResizeHandle, the factory on a
plain element and separatorAttributes each keep tabindex 0 while
disabled.

They fail here: a disabled separator leaves the tab order.
…sizable FR3 says

separatorAttributes gave a disabled separator tabIndex -1, so a disabled
ResizeHandle left the tab order and the factory wrote the same gap onto a
plain element. FR3, after spec:AST-085 FR1, keeps a disabled Handle's tab
stop so keyboard users can reach it and hear that it is disabled. The
derivation now keeps tabIndex 0, and both still ignore input and say
aria-disabled. A [fix] changeset records it.
…s evidence gap names the provider case

component:Resizable no longer lists a disabled Handle leaving the tab
order: its Current-state line goes, the disabled state row no longer
calls the kept tab stop a gap, and the AST-085 relation no longer says
FR3 records one. architecture:internationalization's INV3a evidence gap
now reads: no factory test covers a labels key, or a flat option under a
provider, yet.
…ew one and its target

An accessibility [fix] names what changed from what, and toward what:
before, a disabled ResizeHandle left the tab order; now it keeps its tab
stop, as a disabled control does.
…d state's summary keeps it in the Tab order

The fix below made FR3's implementation gap false: a disabled Handle keeps
tabindex 0. FR3 drops that sentence, and its verification row no longer
lists the disabled tab stop as untested, since ResizeHandle.test.tsx
covers it. The disabled binding state's summary says the handle stays in
the Tab order. A comment on the first-render read says why it starts as
warned.

@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 needs an engineering owner's decision, and changes that stand either way.

Owner decision needed. A hover-revealed Grip pill now gets an invisible 13px extension in ResizeHandle.tsx that keeps the hover and starts a drag, in place of the approved 150 ms hover grace, while the Resizable record's FR1 names only the Grab zone and the painted pill as grab targets. An engineering owner either names the current clause that covers it, or approves it as part of FR1 by commenting /approve-spec <full head SHA> once FR1 states it (the author may do so as an engineering owner).

Required changes:

  • Land #9529 and #9297 first and rebase: the diff carries 4,626 authored lines, and only 1,043 of them are this pull request's own commits.
  • Add a one-line note to the options Effect in useResizable.ts naming what it synchronizes (the factory it passes new options to), as the React component runtime record's INV3 requires of every changed Effect.

The plain-handle path, the shared separator derivation and the disabled tab-stop fix look right, and the Resizable, Layout, SideNav and AppShell suites and the core typecheck pass at this head.

[Automated review]

},
[slot],
);
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 changed Effect names no external system, where architecture:react-component-runtime INV3 asks every new or changed Effect to. It is needed (update reaches the factory, a store outside React, and cannot run during render), so one line above it is enough, for example:

// The factory: each new options object reaches it through update(), as a new render's would.

pointerEvents: 'auto',
backgroundColor: colorVars['--color-border-emphasized'],
},
// A pill shown on hover can sit outside the Grab zone. This invisible

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Owner decision needed: this invisible extension keeps a hover-revealed pill shown and starts a drag in the gap beside the Grab zone, while the Resizable record's FR1 names only the zone and the painted pill as grab targets. An engineering owner either names the clause that covers it or approves it as part of FR1.

This branch was successfully deployed

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