Skip to content

feat(Resizable): resizable takes useResizable's output on LayoutPanel, ResizeHandle and SideNav, and focus leaves a collapsing panel - #9300

Merged
cixzhang merged 8 commits into
meta:mainfrom
vjeux:layout-panel-edge-reveal
Nov 5, 2026
Merged

cixzhang merged 8 commits into
meta:mainfrom
vjeux:layout-panel-edge-reveal

Conversation

@vjeux

@vjeux vjeux commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

On main; it replaces the createResizable stack (#9370, #9372, #9202, closed), with no create… export or handleRef. Per #9973, a slot-prop hook is not a second API (INV21) and needs no create… twin (INV22). The owner scoped the focus fix in. #9989, the slide, is a separate pull request that lands after this one; these 1,222 authored lines are this one's own.

User need

useResizable returns a region, but resizable took only its props, and SideNav took no region. A collapsing LayoutPanel kept focus in a zero-width panel still in the Tab order.

Problem and solution fit

  • The slot prop. LayoutPanel's, ResizeHandle's and SideNav's resizable take the hook's output (resizable={resizable}); its props, true and SideNav's config still work.
  • SideNav. Given the output, the region owns width and collapse: the rail follows isCollapsed, the toggle calls collapse/expand, its autoSaveId saves the width, and its own collapsible decides whether the rail collapses; a collapsible that disagrees or sets a collapsed state is ignored, with a development warning. true and the config behave as before.
  • Focus. Focus inside a collapsing panel moves to its ResizeHandle; the panel is then inert and aria-hidden until it opens.

Current specification

  • New component:Resizable FR9, component:LayoutPanel FR8, component:SideNav FR7 (owner decision, 2026-11-04).
  • component:LayoutPanel AR2 (its gap closes); architecture:public-component-api INV21, INV22.

Public delta and caller burden

Widened prop types only, the three api-report lines: ResizableRegion | ResizableProps on LayoutPanel and ResizeHandle, | ResizableRegion on SideNav. No new export. Changesets: [feat] widened resizable, [fix] focus move.

Design and alternatives

  • An internal resizablePropsOf reads a region's props (one path per component).
  • Focus moves through the shared useFocusLeavingChange; ResizeHandle holds the modality tracker (LayoutPanel PR1).

Evidence

  • Units: 10 slot cases (8 red on main's code; one guards the boolean and config forms) and 11 focus cases (10 red before).
  • A new Resizable example sizes a Card with the hook's size through a StyleX dynamic style. Template rubric 1.4: purity 30, icons 15, CSS 12/15 (the dynamic width), layout 15, images 5, code 10.

Writing rubric results

Over the Resizable, LayoutPanel and SideNav records: three new rows carry the owner's lines, each with a verification row; compatibility lines name the widened type as a patch [feat]; AR2's gap goes. No re-pad. Owner-gated.

  • R1 narration: none.
  • R2 research: none.
  • R3 evidence: rows name tests.
  • R4–R6 alternatives, references, questions: 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

Local checks at this head, on main c5b2c54:

  • The full ui suite (20,232 passed) and node suite (16,499 passed), CI=true pnpm lint (0 errors), check:changesets, check:knowledge, core typecheck, prettier on the changed files, the comment-length check, and pnpm review:pre (1,222 authored lines, no findings).
  • After pnpm build, pnpm api:report changes nothing beyond the three committed prop lines, apart from main's own stale presentation lines, which stay out.
  • Interaction specs for Resizable, Layout and SideNav in Chromium (35 passed) and grabZone, ResizeHandle and SideNavRailMotion in WebKit (23 passed, 12 skipped); the Resizable, Layout and SideNav a11y contracts plus scrollRegionName.a11y.chromium.spec.ts in Chromium (47 passed).
  • Red first: 10 of 11 paneFocus.test.tsx cases fail before the focus fix; 8 of 10 resizableSlot.test.tsx cases fail against main's code. The boolean and config-form guard passes on main and here, as do main's own SideNav collapse-ownership and conflict-warning suites.

@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 4, 2026 10:39pm 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
Contributor

AI review status for this pull request.

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

@github-actions

github-actions Bot commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

🔎 Preview · acbcf08

Storybook · Sandbox · built from this PR's head

Modified components

Component Size LoC Visual · A11y · RTL
LayoutFooter 5.9 kB (0) · 1.6 kB CSS (0) — ✓ · ✓ · ✗
LayoutHeader 6.0 kB (0) · 1.6 kB CSS (0) — ✓ · ✓ · ✗
LayoutPanel 20.3 kB (+1.2 kB) · 4.9 kB CSS (0) — ✓ · ✓ · ✓
9 more components
Component Size LoC Visual · A11y · RTL
AppShell 75.2 kB (+984 B) · 12.2 kB CSS (0) — — · — · —
ResizeHandle 20.4 kB (+840 B) · 4.3 kB CSS (0) — ✓ · ✓ · ✓
SideNav 75.3 kB (+610 B) · 10.1 kB CSS (0) — ✓ · ✓ · ✓
Layout 6.8 kB (0) · 2.3 kB CSS (0) — ✓ · ✓ · ✓
LayoutContent 17.4 kB (0) · 1.7 kB CSS (0) — ✓ · ✓ · ✓
SideNavCollapseButton 46.7 kB (0) · 8.8 kB CSS (0) — ✓ · ✓ · ✓
SideNavHeading 60.6 kB (0) · 10.0 kB CSS (0) — ✓ · ✓ · ✓
SideNavItem 55.6 kB (0) · 9.8 kB CSS (0) — ✓ · ✓ · ✓
SideNavSection 6.6 kB (0) · 3.8 kB CSS (0) — ✓ · ✓ · ✓

Package size: @astryxdesign/core 6.47 MB packed (+9.0 kB); no new dependencies.

API changes: core +0 · ~3 · −0

Committed API report behind this build: cli 9 rows, core 4 rows; run pnpm api:report.

3 API changes (advisory)
Package Where Signature Usage
~ core . · Components · component LayoutPanel prop resizable?: ResizableProps → prop resizable?: ResizableRegion | ResizableProps <LayoutPanel resizable={…} />
~ core . · Components · component ResizeHandle prop resizable?: ResizableProps → prop resizable?: ResizableRegion | ResizableProps <ResizeHandle resizable={…} />
~ core . · Components · component SideNav prop resizable?: boolean | ResizableConfig = false → prop resizable?: boolean | ResizableConfig | ResizableRegion = false <SideNav resizable />

Touched components checked; add ci:run-visual, ci:run-a11y or ci:run-rtl to run the full suites.


Generated by PR Enrichment workflow | View full report

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

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
Contributor

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.

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 ff985c2: a ResizeHandle's own isReversed makes the factory look for its pane on the other side, so an end panel whose region starts collapsed is inert from its first commit. A test covers 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
Contributor

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.

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 ff985c2: the move goes through moveFocusLeftBehind, which moves focus with focusForLastInput.

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

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.

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 #9297: the factory keeps the region while an <Activity> hides its component, so the region comes back at its size and collapse state.

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

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

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: both warnings go through useDevWarning, in #9680 and #9370.

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

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.

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 ff985c2: LayoutPanel notes focus with useFocusLeavingChange, keyed on the region's collapse state, and moves it with moveFocusLeftBehind. The settle state is only for inert.

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

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.

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.

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 ff985c2: a ResizeHandle given resizable props holds the LayoutPanel beside it collapsed, so a panel at width={region.size} moves focus to the handle and goes inert. A test fails before the fix.

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

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

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: both warnings go through useDevWarning, in #9680 and #9370.

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

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.

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 ff985c2: LayoutPanel notes focus with useFocusLeavingChange, keyed on the region's collapse state, and moves it with moveFocusLeftBehind. The settle state is only for inert.

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

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.

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").

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 ff985c2: a ResizeHandle given resizable props holds the LayoutPanel beside it collapsed, so a panel at width={region.size} moves focus to the handle and goes inert. A test fails before the fix.

// 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
Contributor

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.

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: both warnings go through useDevWarning, in #9680 and #9370.

@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 isn't ready to approve: it waits on two spec decisions, and three fixes are needed regardless.

Spec decisions. An engineering owner records each by commenting /approve-spec <full head SHA> on this pull request; the author may do so as an engineering owner.

  • The head rewords component:Resizable FR3 so that createResizable owns the drag, keyboard, collapse, snapping, persistence and size behavior that ResizeHandle and useResizable go through; no current record says ResizeHandle is built on the factory, so either that wording is approved or FR3 keeps its current text.
  • The Grab zone now stays centred on the divider whatever pillPlacement says, shrinks to half of a pane shorter than --spacing-4, and lets the painted pill take the pointer, none of which current FR1 states.

Required changes:

  • ResizeHandle.tsx:617–644 copies createResizable's find-and-hold-the-pane sequence (createResizable.ts:860–881 and :1200–1207), so move it into one helper both call and subscribe to pane changes only while the handle has a region.
  • The Grab zone clamp contradicts FR1's --spacing-4 strip on short panes, so split the Grab zone change into its own pull request with an FR1 amendment, or drop it here.
  • At 9,917 authored lines (7,519 from the stack below and 2,435 of this pull request's own), the diff is far over the 2,000-line scope limit, so land the stack first and retarget this pull request to its own commits.

The slide, the focus move and the new factory otherwise match component:Resizable FR8, component:LayoutPanel FR7 and AR2, and the public API record. Non-blocking: two changesets still call the factory internal, and ResizablePaneLink.render is never called.

[Automated review]

const heldPaneRef = useRef<ResizablePane | null>(null);
const isRegionCollapsed = resizable?._isCollapsed ?? false;
useEffect(() => watchResizablePanes(rerender), []);
useIsomorphicLayoutEffect(() => {

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 repeats createResizable's findPane/setPane (createResizable.ts:860–881) and its pane watcher (:1200–1207): find the lent panel beside the handle, release the old one, hold the new one with the region's collapse state. Two copies already read reversal differently. Could both call one helper in resizablePane.ts (or could a handle given the hook's own props hand its element to the region, so the factory's findPane serves it)? The watcher on :625 also runs for every ResizeHandle, region or 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.

Done in e210af4: ResizeHandle and createResizable find the pane with resizablePaneFor and move their hold with handOverPane. A ResizeHandle watches for lent panes only while it has a region.

// An inline Handle's zone is centred on its line, so half of it reaches into
// the pane. On a pane shorter than the zone, that is at most half the pane.
hitAreaLengthX: (pane: number) => ({
width: `min(${spacingVars['--spacing-4']}, ${pane}px)`,

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.

component:Resizable FR1 states the Grab zone is "a --spacing-4 strip on the resize axis"; this caps it at the pane's length, so a 12px item gets a 12px zone. The centring, this clamp and the pill as a pointer target need an FR1 amendment an engineering owner approves; they are separable from the slide, so they could move to their own pull request.

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.

FR1 states it now: the owner-approved amendment in #9529, which this carries.

Comment thread .changeset/create-resizable-internal.md Outdated
'@astryxdesign/core': patch
---

[chore] The configuration-object `useResizable` builds on one internal resize region, through the same size model as before, so the region's state has one implementation.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking: this and create-resizable-plain-handle.md call the factory internal, while create-resizable.md announces it as public in the same release. Folding them into the public entry keeps the release notes consistent.

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.

Folded into the public entry in #9372's 988c942, which this carries.

@vjeux
vjeux force-pushed the layout-panel-edge-reveal branch from ff985c2 to 44ba216 Compare November 3, 2026 06:49
@github-actions github-actions Bot removed the needs:spec-owner-review Current knowledge records await owner approval label Nov 4, 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 from a fork; once it is ready for review, a maintainer adds the ci:run label so CI runs.

The widened resizable slot and SideNav's region path follow the component:Resizable FR9, component:LayoutPanel FR8 and component:SideNav FR7 rows an engineering owner approved at this head, and the focus move meets component:LayoutPanel AR2, shown by new tests that fail on main and pass here and by a Chromium check. Three non-blocking suggestions are inline: two comment cleanups and isolating the console.warn spies in resizableSlot.test.tsx.

[Automated review]

});

const collapsed = resizableHook.isCollapsed;
// The region that sizes the rail: the caller's, or SideNav's own.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking, missed earlier (INV29): this restates const region = ownerRegion ?? resizableHook on the next line; it can go.

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.

Agreed. This head is approved and ready, so it takes no more pushes; the line goes in a follow-up after it merges.

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.

Done in #10032.

return sibling == null ? null : (lent.get(sibling) ?? null);
}

/** The lent panel a handle sizes: before it, or after it when reversed. */

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking, missed earlier (INV29): resizablePaneBeside above is exported but only this function calls it, and both JSDoc blocks say the same thing. Folding them into one function with one comment would remove the repeat.

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.

Agreed. #9989 replaces this module when it rebases after this merges, and folds the two functions into one there.

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.

Correction: the fold went into #10032 instead, so it lands without waiting for #9989.

const pane = () => screen.getByTestId('pane');
const separator = () => screen.getByRole('separator', {name: 'Resize pane'});

afterEach(() => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking, missed earlier: the console.warn spies are restored at the end of each case body, so a failing case leaves its spy behind and the next case reads its calls (against the base code the boolean-and-config guard fails only for that reason). Adding vi.restoreAllMocks() here would isolate the cases.

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.

Agreed: a failing case should not leak its spy into the next. This head is ready, so it takes no more pushes; the vi.restoreAllMocks() cleanup goes in a follow-up after it merges.

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.

Done in #10032, with the leak shown before and after.

@vjeux
vjeux marked this pull request as ready for review November 4, 2026 20:58
@cixzhang cixzhang added the ci:run A maintainer lets this outside contributor's pull request run CI (AST-030 FR17) label Nov 4, 2026
@cixzhang
cixzhang marked this pull request as draft November 4, 2026 21:01
@cixzhang
cixzhang marked this pull request as ready for review November 4, 2026 21:02
… panel is inert

Cases for a LayoutPanel beside its ResizeHandle. When the region
collapses with focus inside the panel, focus moves to the handle, with the
ring the last input calls for, and the panel is inert and hidden from
assistive technology until it opens. Opening moves no focus, and a
collapse started from the handle or from outside the panel leaves focus
where it is. This holds for the hook's own props, a hand-built object, a
reversed end panel, and a panel at the hook's size beside a handle given
the props, from the first commit when the region starts collapsed. A panel
with no resize controller is never inert. These cases fail until the next
commit.
@vjeux
vjeux marked this pull request as draft November 4, 2026 22:12
vjeux added 7 commits November 4, 2026 14:12
…panel is inert

A collapsing LayoutPanel moves focus inside it to its ResizeHandle,
through the focus-leaving sequence that disclosures share, so the ring
follows the last input. A commit later the panel goes inert and
`aria-hidden` until it opens, so inert never drops that focus to the page.
The panel reads the collapse state from its `resizable` props, or, with
no `resizable`, from the ResizeHandle beside it: a new internal module,
resizablePane.ts, lets a panel at the region's size lend its element to
that handle, which holds it collapsed with the region. ResizeHandle holds
the shared modality tracker and registers its element, through a callback
ref, for the panel to find. No prop, option, export or type changes.
…ile collapsed

The `resizable` and `width` descriptions, in English, Chinese and dense
form, say that focus moves to the handle as a panel collapses and that
the panel is inert while collapsed. A `[fix]` changeset says the same.
…ollapsed panel as missing

AR2's implementation gap closes: focus leaves a collapsing panel, and
the collapsed panel is inert. The current-state note keeps only the slide
(FR7), which is still missing.
…ble's output in `resizable`

Cases for each `resizable` slot given the hook's output directly, beside
the same cases for its `props`: the panel takes the region's size, the
handle drives the region, and a collapse makes the panel inert. SideNav
given the output sizes its rail from the region and follows the region's
collapse state, and its toggle calls the region's `collapse` and
`expand`. With the hook's output, the region's own `collapsible` option
decides whether the rail collapses. When `collapsible` sets its own
collapsed state, or disagrees with the region's option, the region wins
and SideNav warns in development, naming the region; when they agree it
warns about nothing. A guard case keeps the boolean and config forms as
they were: `collapsible` turns collapse on beside them, and only
collapse-state keys conflict, with `resizable` winning; it passes on
main too. The cases for the output fail until the next commit.
…ble's output in `resizable`

Additive: `resizable` on LayoutPanel and ResizeHandle takes the hook's
output directly, `const resizable = useResizable({…});
<LayoutPanel resizable={resizable}>`, and still takes its `props`.
SideNav's `resizable` also takes the output, beside `true` and its config
object. The caller's region then owns the width and the collapse state:
the rail follows its `isCollapsed`, the toggle calls its `collapse` and
`expand`, and its `autoSaveId` saves the width. With the hook's output,
the region's own `collapsible` option decides whether the rail
collapses. When `collapsible` sets its own collapsed state, or disagrees
with the region's option, the region wins and SideNav warns in
development, as `resizable`'s keys already override `collapsible`'s.
With `true` or a config object, SideNav behaves exactly as before.
…any element with it

The docs and stories name the hook's value `resizable` and pass it
directly: `resizable={resizable}`. LayoutPanel's, ResizeHandle's and
SideNav's prop docs say `resizable` takes the output, or its `props`,
and SideNav's say its region then owns the width and the collapse state.
A new Resizable example sizes a Card that is not a LayoutPanel with the
hook's `size`, through a StyleX dynamic style, beside a ResizeHandle. A
`[feat]` changeset describes the widened prop.
…d SideNav's region wins

component:Resizable FR9: `resizable` takes useResizable's output
directly, the region's `props` is still accepted, and components take the
hook's value through that slot prop (owner decision, 2026-11-04).
component:LayoutPanel FR8 states it for the panel. component:SideNav FR7
states it for SideNav, with the owner's rule for a region beside
`collapsible`'s own collapsed state: the region wins, and SideNav warns in
development. FR7 adds the spec owners' sentence for the hook's output
only: the region's own `collapsible` option also decides whether the
rail collapses. Each record's verification table gains the row for its
new requirement. The three records' Builder impact, Compatibility and
public-contract lines name the widened `resizable` as a patch `[feat]`;
LayoutPanel's Compatibility row keeps its cell width, so no other row
re-pads.
@vjeux
vjeux force-pushed the layout-panel-edge-reveal branch from 6f21e03 to acbcf08 Compare November 4, 2026 22:29
@github-actions github-actions Bot added the needs:spec-owner-review Current knowledge records await owner approval label Nov 4, 2026
@cixzhang

cixzhang commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

/approve-spec acbcf08

@github-actions github-actions Bot removed the needs:spec-owner-review Current knowledge records await owner approval label Nov 4, 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 from a fork; once it is ready for review, a maintainer adds the ci:run label so CI runs.

The widened resizable slot and SideNav's region path follow the component:Resizable FR9, component:LayoutPanel FR8 and component:SideNav FR7 rows an engineering owner approved at this head, and the focus move meets component:LayoutPanel AR2, shown by new tests that fail on main and pass here and by a Chromium check. The rebase onto main merges the scrolling-panel naming into LayoutPanel's ref correctly. The three non-blocking suggestions from the earlier review (two comment cleanups and isolating the console.warn spies in resizableSlot.test.tsx) still apply as follow-ups.

[Automated review]

@vjeux
vjeux marked this pull request as ready for review November 4, 2026 22:58
@cixzhang
cixzhang marked this pull request as draft November 4, 2026 23:08
@cixzhang
cixzhang marked this pull request as ready for review November 4, 2026 23:09
@cixzhang
cixzhang merged commit 1fec9fa into meta:main Nov 5, 2026
159 of 169 checks passed
@vjeux
vjeux deleted the layout-panel-edge-reveal branch November 5, 2026 03:16

This branch was successfully deployed

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants