Skip to content

feat(Resizable): createResizable is public, and component:Resizable FR3 names it as the owner ResizeHandle and useResizable go through - #9372

Draft
vjeux wants to merge 12 commits into
meta:mainfrom
vjeux:resizable-config-form-on-factory
Draft

vjeux wants to merge 12 commits into
meta:mainfrom
vjeux:resizable-config-form-on-factory

Conversation

@vjeux

@vjeux vjeux commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

The third of five stacked pull requests for createResizable. It is built on useResizable(handleRef, options) (#9370) and merges after it; this branch carries the commits of #9297 and #9370. This head's own changes: vjeux/astryx@4905b79...7a6ab57. The LayoutPanel slide (#9300) and the deprecation of the configuration-object form (#9202) are stacked on it.

User need

architecture:public-component-api INV22 has every public interaction hook delegate to a public create…(element, options) factory (spec:AST-107 FR6), and lists useResizable among the hooks without one. After #9297 and #9370 both forms of useResizable and both paths of ResizeHandle go through createResizable, but the factory is internal: a page without React cannot make a divider a window splitter, and component:Resizable FR3 still names ResizeHandle and useResizable as the owners of the behavior.

Problem and solution fit

  • The factory is public. createResizable(handle, options) → {size, isCollapsed, collapse, expand, resize, update, subscribe, destroy}, with its CreateResizableOptions and ResizableController types, is exported from @astryxdesign/core/Resizable and the root. It makes a plain element a window splitter that resizes a region, without React: a pointer drag that holds its pointer across embedded frames, the arrow keys, Home, End, Enter and a double-click, with bounds, snap points, collapse and saved sizes. It reports the size; the caller sizes the panel.
  • FR3 is true, in the same pull request. component:Resizable FR3 names createResizable as the owner ResizeHandle and useResizable go through, so the public API and its record line land together.
  • Docs. createResizable's doc page sits in the Resizable group beside useResizable, hidden from the components gallery (a factory is not a component); the docsite's route lock names its page, and the doc compiler pins it by name. useResizable's page names it as the factory both forms wrap. The doc page says update() takes the full set of options, as a new render does.
  • Strings outside a provider. Used without React, outside a provider, the handle's name and its collapsed text are the shipped English catalog's messages (architecture:internationalization INV3); label names the handle in another language. How non-React callers localize the collapsed text is pending a system-wide decision by the owner, since no existing factory announces text and this one would set the precedent; this pull request merges after that answer. The docs state the English default and nothing beyond it.

Current specification

  • spec:AST-107 FR6
  • architecture:public-component-api INV22
  • architecture:internationalization INV3
  • component:Resizable FR3 (edited here)

Public delta and caller burden

New exports from @astryxdesign/core/Resizable and the root: createResizable, CreateResizableOptions and ResizableController; the core API report gains their rows. Nothing existing changes.

const resizable = createResizable(divider, {defaultSize: 260, minSize: 180});
const paint = () => {
  sidebar.style.width = `${resizable.size}px`;
};
paint();
resizable.subscribe(paint);

Design and alternatives

Writing rubric results

Record touched: component:Resizable (FR3). Only FR3's owner sentence changes; no other row changes.

  • R1 narration: none. FR3's new line is in the present tense, and a scan of the record for proposal or history wording found none.
  • R2 research: none in the record.
  • R3 evidence: FR3's basis cell reads "Current source, docs, and tests"; no pull request or commit is named.
  • R4 rejected alternatives: two, each a Rejected: line inside its decision; untouched here.
  • R5 change references: none.
  • R6 open questions: none.

Evidence

Scope

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

Testing

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

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

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

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

It is not exported from the package, so the changeset records it as
maintenance.
@vercel

vercel Bot commented Nov 1, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
astryx Ready Ready Preview Nov 1, 2026 8:36pm 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
@github-actions

github-actions Bot commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

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


Generated by PR Enrichment workflow | View current CI run

@astracat-bot

astracat-bot Bot commented Nov 1, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

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

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

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

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

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

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

createResizable, and the move of both useResizable forms and ResizeHandle onto it, fit the useResizable gap that architecture:public-component-api INV22 lists, but approval waits on an engineering-owner decision. An engineering owner needs to approve, for this exact head, the rewritten component:Resizable FR3 owner sentence and the public rows no current record names (ResizableController.update and subscribe, and the label, isReversed and isDisabled options) by commenting /approve-spec 062b726388ccc2efe77e3d943d83aef3eb846480; as an engineering owner, you can record it yourself. Two changes are still needed after that:

  • A factory recreated before its container is measured (a ResizeHandle that mounts after the hook, or an <Activity> hide and show of the configuration-object form) keeps and saves the temporary 1200px basis, so a '30%' default stays 360px instead of 240px (useResizable.ts:572-584).
  • The diff still carries #9297 and #9370 (5508 lines); merging those first leaves this pull request's own 1894 lines.

[Automated review]

Comment on lines +572 to +584
const previous =
entry == null
? slot.remembered()
: readResizableSelection(entry.resizable);
entry?.resizable.destroy();
const createdWith =
previous == null
? optionsRef.current
: {
...optionsRef.current,
defaultSize: previous.size,
defaultIsCollapsed: previous.isCollapsed,
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

When the old factory was still on the temporary 1200px basis (a containerRef that is not laid out yet), this passes that placeholder size as a pixel defaultSize, so the new factory never resolves the percentage against the real container and saves the placeholder: defaultSize: '30%' stays 360px after the container measures 800px instead of 240px, with a ResizeHandle that mounts after the hook or an <Activity> hide and show. Carrying the previous selection only when it was made against a measured basis would keep AST-010's rule that the temporary basis is never final or persisted.

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: a factory made for a reveal or a late handle takes the region's size only when it was chosen against a measured basis, so a '30%' default resolves to 240px once the container measures. Tests cover both cases.

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

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

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

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

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

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

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

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

component:Resizable FR3 owned the drag, keys, double-click, snapping,
persistence, reversal and size in ResizeHandle and useResizable. Both
forms of the hook and both paths of the handle go through
createResizable, now public, so FR3 names it as the owner they go
through (architecture:public-component-api INV22).

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

Approval waits on an engineering-owner decision: component:Resizable FR3's reworded owner sentence and the public rows no current record names (ResizableController.update and subscribe, and the label, isReversed and isDisabled options) need a spec-owner approval for this exact head, which an engineering owner records by commenting /approve-spec 7a6ab579d185b006e69bbb75b1c656e387767184; you may record it yourself if you are an engineering owner. The earlier placeholder-basis issue is fixed, and ResizeHandle behaves as before in the stories checked, but three changes are still needed after that:

  • A recreated factory drops a selection when its percentage bound's container measures 0: with maxSize: '50%', a region resized to 300px comes back at its 200px default after an <Activity> hide and show (or a ref-form handle that attaches late), where the base keeps 300px (useResizable.ts:582-591).
  • .changeset/create-resizable.md says label replaces the announcement too; it names the handle only, and the collapsed state is always announced in English.
  • The diff still carries #9297 and #9370 (5654 lines); merging those first leaves this pull request's own 594 lines.

[Automated review]

Comment on lines +582 to +591
const createdWith =
previous == null
? optionsRef.current
: {
...optionsRef.current,
...(previous.size === undefined
? {}
: {defaultSize: previous.size}),
defaultIsCollapsed: previous.isCollapsed,
};

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 previous size reaches the new factory as a pixel defaultSize, and the options effect at lines 615-620 then calls update(options) with the caller's own defaultSize. When the new factory has a percentage bound and its container measures 0 at that moment, the default is not final yet, so the update replaces the carried size: with defaultSize: 200, maxSize: '50%', a region resized to 300px comes back at 200px once the container measures, after an <Activity> hide and show (the base keeps 300px) or a ref-form handle that attaches after resize(300). Passing the previous selection as the factory's selection (an internal seed for chosenSize) rather than as defaultSize would keep it through the update.

'@astryxdesign/core': patch
---

[feat] `createResizable(handle, options)` makes a plain element a window splitter that resizes a region, without React: a pointer drag that holds its pointer across embedded frames, the arrow keys, Home, End, Enter and a double-click, with bounds, snap points, collapse and saved sizes. It reports the size through `subscribe` and `onSizeChange`, and you size the panel; `destroy` removes every listener and attribute it added. The handle is named and announced with the message catalog's English messages, unless you pass `label`. `useResizable(handleRef, options)` is its React form.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

label sets only the handle's name; a bare factory always announces its collapsed state with the English catalog's "Collapsed" (createResizable.ts:805-808). Suggested: "The handle is named with the catalog's English message unless you pass label, and announces its collapsed state in English."

This branch was successfully deployed

1 active deployment
Preview — 7a6ab579 Deployed Nov 1, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot. needs:spec-owner-review Current knowledge records await owner approval

1 participant