diff --git a/.changeset/collapsing-panel-moves-focus.md b/.changeset/collapsing-panel-moves-focus.md
new file mode 100644
index 0000000000000..f2230b651e48f
--- /dev/null
+++ b/.changeset/collapsing-panel-moves-focus.md
@@ -0,0 +1,7 @@
+---
+'@astryxdesign/core': patch
+---
+
+[fix] When a `LayoutPanel` beside a `ResizeHandle` collapses with focus inside it, focus moves to the handle, and the collapsed panel is inert and hidden from assistive technology until it opens, instead of keeping focus, and a Tab stop, in a panel of no width. This holds for a panel that takes a `useResizable` region's `resizable` props, whether the hook's own or a hand-built object, and for a panel at the region's size beside a handle given them. A collapse started from the handle or from outside the panel keeps focus where it is.
+
+@vjeux
diff --git a/.changeset/resizable-takes-hook-output.md b/.changeset/resizable-takes-hook-output.md
new file mode 100644
index 0000000000000..7832eda7262d7
--- /dev/null
+++ b/.changeset/resizable-takes-hook-output.md
@@ -0,0 +1,7 @@
+---
+'@astryxdesign/core': patch
+---
+
+[feat] `LayoutPanel`, `ResizeHandle` and `SideNav` take `useResizable`'s output directly in their `resizable` prop: `const resizable = useResizable({…}); …`. The output's `props` still works, and `SideNav`'s `true` and config object are unchanged. Given the output, `SideNav`'s rail follows the region: its width and collapse state come from the region, its toggle calls the region's `collapse` and `expand`, and the region's `autoSaveId` saves the width. When `collapsible` also sets a collapsed state, the region wins and `SideNav` warns in development. Any other element can use the hook's `size`; the Resizable docs show one sized with a StyleX dynamic style.
+
+@vjeux
diff --git a/apps/storybook/stories/Resizable.stories.tsx b/apps/storybook/stories/Resizable.stories.tsx
index da8f4314103e9..81c0def763be9 100644
--- a/apps/storybook/stories/Resizable.stories.tsx
+++ b/apps/storybook/stories/Resizable.stories.tsx
@@ -48,7 +48,7 @@ type Story = StoryObj;
/** Basic horizontal split with divider line. */
export const HorizontalSplit: Story = {
render: () => {
- const sidebar = useResizable({
+ const resizable = useResizable({
defaultSize: 250,
minSize: 150,
maxSize: 500,
@@ -59,11 +59,11 @@ export const HorizontalSplit: Story = {
height="fill"
start={
<>
-
+ Sidebar
- {sidebar.size}px
+ {resizable.size}px
Drag the handle to resize. Arrow keys when focused.
@@ -73,7 +73,7 @@ export const HorizontalSplit: Story = {
>
@@ -95,7 +95,7 @@ export const HorizontalSplit: Story = {
/** Vertical split with divider line. */
export const VerticalSplit: Story = {
render: () => {
- const top = useResizable({
+ const resizable = useResizable({
defaultSize: 250,
minSize: 100,
maxSize: 350,
@@ -106,19 +106,19 @@ export const VerticalSplit: Story = {
+
Editor
- {top.size}px
+ {resizable.size}px
@@ -140,7 +140,7 @@ export const VerticalSplit: Story = {
/** Collapsible sidebar — drag past threshold or double-click to collapse. */
export const Collapsible: Story = {
render: () => {
- const sidebar = useResizable({
+ const resizable = useResizable({
defaultSize: 260,
minSize: 180,
collapsible: true,
@@ -152,12 +152,12 @@ export const Collapsible: Story = {
height="fill"
start={
<>
- {!sidebar.isCollapsed && (
-
+ {!resizable.isCollapsed && (
+ Sidebar
- {sidebar.size}px
+ {resizable.size}pxDouble-click handle or press Enter to collapse.
@@ -166,7 +166,7 @@ export const Collapsible: Story = {
>
@@ -176,10 +176,10 @@ export const Collapsible: Story = {
Content
- Sidebar is {sidebar.isCollapsed ? 'collapsed' : 'expanded'}.
- {sidebar.isCollapsed && (
+ Sidebar is {resizable.isCollapsed ? 'collapsed' : 'expanded'}.
+ {resizable.isCollapsed && (
@@ -225,7 +225,7 @@ export const ThreePanelIDE: Story = {
>
@@ -249,7 +249,7 @@ export const ThreePanelIDE: Story = {
-
- {sidebar.size}px
+
+ {resizable.size}px
>
diff --git a/docs/specs/components/Layout/LayoutPanel.spec.md b/docs/specs/components/Layout/LayoutPanel.spec.md
index 46994b60a3d30..77e755e4145c5 100644
--- a/docs/specs/components/Layout/LayoutPanel.spec.md
+++ b/docs/specs/components/Layout/LayoutPanel.spec.md
@@ -42,8 +42,8 @@ system_specs: [spec:AST-105]
| Public contract | `children`, `width`, `resizable`, `hasDivider`, `isScrollable`, `padding`, `label`, `role`, root `BaseProps`, and a root `ref`. |
| Behavior | A fixed-width side region at logical start or end that scrolls by default, with an optional divider on its content-facing edge. |
| End-user impact | People see a side panel that keeps its width, scrolls on its own, and mirrors to the other side in right-to-left. |
-| Builder impact | None: no API change; a collapsing resizable panel slides, and a scrolling panel gains a role and a name, with no code change. |
-| Compatibility/readiness | No API or default changes; a collapsing resizable panel slides (FR7) and moves focus out, then becomes inert (AR2), and a scrolling panel is named, as a group (AR3): patch, `[feat]` slide and `[fix]` focus and name. |
+| Builder impact | `resizable` takes useResizable's output directly, or its `props` as before (FR8); a collapsing resizable panel slides, and a scrolling panel gains a role and a name, with no code change. |
+| Compatibility/readiness | `resizable` takes useResizable's output too (FR8). A collapsing panel slides (FR7), moves focus out and goes inert (AR2), and a scrolling panel is named (AR3). Patch: `[feat]` type and slide, `[fix]` focus and name. |
| Review checks | Reject a divider on the outer edge, a panel that shrinks, an inferred landmark, `width` beating `resizable`, or a further publication mismatch. |
| Governing rules | `family:layout-regions` FR1, FR2, FR4–FR8, FR10; `architecture:container-padding`; `architecture:public-component-api` INV5–INV8. |
@@ -61,11 +61,12 @@ Consumer syntax and examples belong in `LayoutPanel.doc.mjs`.
- Released default preserved: `yes` — no divider, scrollable, automatic
inset, content width.
-- Compatibility class: patch. No public prop, default, target, or styling
- output changes. A collapsing resizable panel now slides (FR7, `[feat]`,
- visual-only under `spec:AST-017/DEC-17`), and focus inside it moves to the
- adjacent ResizeHandle while the collapsed panel becomes inert (DOM: `inert`
- and `aria-hidden`; AR2, `[fix]` under `spec:AST-017` FR53). While it
+- Compatibility class: patch. `resizable` also takes useResizable's output (FR8,
+ `[feat]`); no other public prop, target, or styling output changes, and
+ omitted props behave as before. A collapsing resizable panel now slides (FR7,
+ `[feat]`, visual-only under `spec:AST-017/DEC-17`), and focus inside it moves
+ to the adjacent ResizeHandle while the collapsed panel becomes inert (DOM:
+ `inert` and `aria-hidden`; AR2, `[fix]` under `spec:AST-017` FR53). While it
scrolls, a panel with no caller `role` gains `role="group"` and an
`aria-label` (DOM; AR3, `[fix]` under `spec:AST-017` FR53).
- Controlled/uncontrolled behavior: not applicable
@@ -85,10 +86,8 @@ Consumer migration instructions belong in consumer docs and release notes.
`hasDivider` beside a ResizeHandle that draws the same line paints the
boundary twice; nothing prevents it, so callers keep `hasDivider={false}`
when the ResizeHandle owns the line (`family:layout-regions` FR5).
-- **A collapsing panel jumps and keeps focus (FR7, AR2).** A resizable panel
- closes and opens at once, where `spec:AST-105` FR9 slides it, and a panel
- collapsed by app code keeps focus inside it and its controls in the tab
- order while it is out of sight.
+- **A collapsing panel jumps (FR7).** A resizable panel closes and opens at
+ once, where `spec:AST-105` FR9 slides it.
## Ownership boundary
@@ -127,6 +126,7 @@ Consumer migration instructions belong in consumer docs and release notes.
| FR5 | `role` and `label` set the root's `role` and `aria-label`; LayoutPanel adds a role and a name of its own only while it scrolls (AR3). | Current source and tests; `family:layout-regions` FR8 |
| FR6 | `ref` reaches the root. Supported DOM, data, ARIA, and event props reach the root after the component's own attributes; `xstyle`, `className`, and `style` compose after component styles. | Current source and tests; `architecture:public-component-api` INV5–INV8 |
| FR7 | A LayoutPanel whose size comes from a resize controller lends its element to the controller of the ResizeHandle on its content-facing edge through an internal channel, so the panel slides closed and open by `spec:AST-105` FR9's edge reveal when its region collapses or opens: its content-facing edge (FR2) moves, and the content on that edge's side moves on the same motion (`component:Resizable` FR8). A panel given the configuration-object form, a hand-built `ResizableProps` object that `useResizable` did not return passed through `resizable`, and a panel with no resize controller, never slide; `useResizable`'s own `props` passed through `resizable` do slide. LayoutPanel adds no prop. Implementation gap: no panel slides yet (Current-state impact). | `spec:AST-105` FR9; `component:Resizable` FR8 |
+| FR8 | `resizable` takes useResizable's output directly; the region's `props` is still accepted (owner decision, 2026-11-04). The panel takes the hook's value through that slot prop (owner decision, 2026-11-04). | `component:Resizable` FR9; `architecture:public-component-api` INV21 |
### Allowed variation
@@ -169,8 +169,6 @@ Consumer migration instructions belong in consumer docs and release notes.
until it opens (`spec:AST-105` FR9). Opening moves no focus. A collapse
started from the Handle, or from a control outside the panel, leaves focus
where it is. Only the slide (FR7) excludes the configuration-object form.
- Implementation gap: focus stays in a collapsed panel, which stays reachable
- (Current-state impact).
- **AR3 — A scrolling panel is named.** While the panel scrolls, its
scrolling element has `role="group"` (unless the caller sets a `role`) and
an accessible name (the caller's `label`, or a translated "Content"), so a
@@ -214,6 +212,7 @@ of its own, so no anatomy-theming block is declared here.
| AR3 | `useScrollRegionProps.test.tsx` LayoutPanel tests; `scrollRegionName.a11y.chromium.spec.ts` `panel-scrolls` | Scrolling panel; caller `navigation` role and label | A group role or name missing while the panel overflows fails, at Chromium's accessibility tree too; a caller's role or label lost fails. | `audit:LayoutPanel/accessibility` |
| PR1 | Source review | Every state | Gap: no automated check fails if LayoutPanel adds a listener, timer, or measurement. | `audit:LayoutPanel/behavior` |
| RTL relation | RTL audit curated D2 target (`apps/storybook/rtl-audit/targets.json`) on the `Content width with start panel` story | Start panel beside the content; LTR and RTL | A start panel that is not at the inline start of the content in both directions fails D2. | `audit:LayoutPanel/i18n-rtl` |
+| FR8 | `resizableSlot.test.tsx` region and `props` cases | The region; its `props` | A panel that ignores the region's size, or a collapse that leaves it reachable, fails. | `audit:LayoutPanel/api` |
## Decision log
diff --git a/docs/specs/components/Resizable/Resizable.spec.md b/docs/specs/components/Resizable/Resizable.spec.md
index 4ef0676b3c069..083b69a1818e8 100644
--- a/docs/specs/components/Resizable/Resizable.spec.md
+++ b/docs/specs/components/Resizable/Resizable.spec.md
@@ -45,8 +45,8 @@ system_specs: [spec:AST-010, spec:AST-020, spec:AST-085, spec:AST-105]
| Public contract | `useResizable` (size, bounds, collapse, snaps, persistence) and `ResizeHandle` (`direction`, `position`, `isReversed`, `isDisabled`, `hasDivider`, `isAlwaysVisible`, `pillPlacement`, `label`, `resizable`, `children`), plus the `pixel()` and `percent()` size helpers. |
| Behavior | The hook owns a region's size in pixels; the Handle is a focusable window splitter that resizes it by pointer and keyboard; a pane a LayoutPanel renders slides on collapse and open. |
| End-user impact | People drag or use the keyboard to resize a panel, collapse and restore it, and have the size remembered, with the divider moving the way the page reads. |
-| Builder impact | None: no API change; a LayoutPanel's collapse slides with no code change. |
-| Compatibility/readiness | No API or default changes; a collapsing LayoutPanel pane slides and moves focus out, then becomes inert (FR8; `component:LayoutPanel` AR2): patch, `[feat]` slide and `[fix]` focus. Implementation gaps are listed under Current-state impact. |
+| Builder impact | `resizable` on LayoutPanel and ResizeHandle takes useResizable's output directly, or its `props` as before (FR9); a LayoutPanel's collapse slides with no code change. |
+| Compatibility/readiness | `resizable` also takes useResizable's output (FR9; patch `[feat]`); other props unchanged. A collapsing LayoutPanel pane slides and moves focus out, then becomes inert (FR8; `component:LayoutPanel` AR2): patch, `[feat]` slide and `[fix]` focus. Implementation gaps are listed under Current-state impact. |
| Review checks | Reject a Handle that loses its splitter role, values, or keys, arrows that move against the reading direction, a percentage default that rescales, or settled state, paint, storage, and ARIA that disagree; storage holds the last settled size and receives the final size once when a drag ends, cancels, or unmounts. |
| Governing rules | `spec:AST-010`; `spec:AST-020` FR15 (APG Window Splitter); `spec:AST-085`; `spec:AST-105` FR9; `architecture:interaction-modality`; `architecture:component-theming-surface`; `architecture:public-component-api`. |
@@ -72,7 +72,8 @@ Consumer syntax and examples belong in `Resizable.doc.mjs` and
slides (FR8, `[feat]`, visual-only under `spec:AST-017/DEC-17`), and focus
inside it moves to its ResizeHandle while the collapsed pane becomes inert
(DOM: `inert` and `aria-hidden`; `component:LayoutPanel` AR2, `[fix]` under
- `spec:AST-017` FR53). No prop, option, or return member changes.
+ `spec:AST-017` FR53). `resizable` also takes useResizable's output (FR9,
+ `[feat]`); no other prop, option, or return member changes.
- Controlled/uncontrolled behavior: collapse may be controlled
(`isCollapsed`, `onCollapseChange`) or uncontrolled (`defaultIsCollapsed`);
size is hook-owned.
@@ -150,6 +151,7 @@ Consumer migration instructions belong in consumer docs and release notes.
| FR6 | `minSize` and `maxSize` are the bound props; numeric values are pixels. When resolved bounds conflict, the maximum wins. | `spec:AST-010`; current source and tests |
| FR7 | `ref`, DOM, data, ARIA, and event props reach the Handle, and a caller's explicit `role`, ARIA attribute, or `tabIndex` reaches it as given; a caller `onKeyDown` runs before the Handle's own key handling; caller `onFocus`, `onBlur`, and `onDoubleClick` compose with the Handle's own focus tracking and double-click collapse (`architecture:public-component-api` INV5, INV7); `xstyle` and `className` compose after component styles, and `style` is not accepted. Implementation gap: caller `onFocus`, `onBlur`, and `onDoubleClick` replace the Handle's own handlers (Current-state impact). | Current source; `architecture:public-component-api` INV5–INV8 |
| FR8 | When a region collapses or opens, its pane slides by `spec:AST-105` FR9's edge reveal when a LayoutPanel renders it (`component:LayoutPanel` FR7): the resize controller measures the pane's box before and after the collapse state commits, before its subscribers hear of it, and the pane's edge facing its ResizeHandle, read from the region's direction and reversal, moves with the content on that edge's side on one motion. Every collapse and open plays it: `collapse()`, `expand()`, a resize out of collapse, Enter, a double-click, the grow arrow, a controlled `isCollapsed` change, and a drag that crosses `collapsedSize` to collapse. A drag that reopens a collapsed pane plays nothing, because the pointer drives the edge; a resize that neither collapses nor opens, and the first render, play nothing. A pane the caller renders at the hook's size alone, and a LayoutPanel given the configuration-object form, a hand-built `ResizableProps` object that `useResizable` did not return passed through its `resizable`, show no slide; the hook's own `props` passed through `resizable` do slide. No prop, option or return member is added; size, `isCollapsed`, callbacks, persistence and the Handle's ARIA values are unchanged. Focus inside a collapsing pane follows `component:LayoutPanel` AR2. Implementation gap: no collapse or open slides yet (Current-state impact). | `spec:AST-105` FR9; `component:LayoutPanel` FR7, AR2 |
+| FR9 | `resizable` takes useResizable's output directly; the region's `props` is still accepted (owner decision, 2026-11-04). Components take the hook's value through the `resizable` slot prop (owner decision, 2026-11-04): LayoutPanel (`component:LayoutPanel` FR8), ResizeHandle, and SideNav (`component:SideNav` FR7). Any other element can use the hook's `size`. | `architecture:public-component-api` INV21, INV22 |
### Allowed variation
@@ -279,6 +281,7 @@ Consumer migration instructions belong in consumer docs and release notes.
| PR1 | `useResizable.test.ts` observer tests | Pixel, percentage default, percentage bound | An observer on a pixel-only path, or one that outlives a final default, fails. | `audit:Resizable/behavior` |
| Theming anatomy map | `scripts/check-knowledge.mjs` | Canonical anatomy and two targets | Missing, extra, or stale mappings fail repository validation. | `audit:Resizable/theming` |
| RTL relation (ResizeHandle) | RTL audit curated D4 target (`apps/storybook/rtl-audit/targets.json`) on the `Horizontal split` story, beside the Resizable target | Horizontal split; LTR and RTL | A handle that does not move to the mirrored side of the layout in RTL fails D4. | `audit:Resizable/i18n-rtl` |
+| FR9 | `resizableSlot.test.tsx`: a LayoutPanel and its ResizeHandle given the region, and given its `props` | The region; its `props` | A panel that ignores the region's size, a handle that does not drive it, or a collapse that leaves the panel reachable fails | `audit:Resizable/api` |
## Decision log
diff --git a/docs/specs/components/SideNav/SideNav.spec.md b/docs/specs/components/SideNav/SideNav.spec.md
index 38d27c73db902..c8407a2469c2f 100644
--- a/docs/specs/components/SideNav/SideNav.spec.md
+++ b/docs/specs/components/SideNav/SideNav.spec.md
@@ -46,10 +46,10 @@ its source, tests, and consumer docs until a record claims it.
| Area | Contract |
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| Public contract | None. No prop, value, default, or theme target changes. |
+| Public contract | `resizable` also takes useResizable's output, beside `true` and its config object (FR7); no other prop, value, or theme target changes, and omitted props behave as before. |
| Behavior | Every SideNavItem row spans the full width of its nav list at any depth. Depth shows as inline-start indent inside the row: one `--spacing-6` step per level, added to the row's own padding. Collapsing or expanding the rail snaps the layout to its final width and clips the labels in or out; a collapse ends icons-only and an expand ends with its labels (FR6). |
| End-user impact | A person scanning a nested nav sees hover, pressed, selected, and focus fills that line up with the top-level rows, and can click a nested row's indent to activate it, as they would its label. Collapsing the rail moves no layout frame by frame (FR6). |
-| Builder impact | No new choice or prop. Icons and labels keep their per-depth position. Content placed directly inside a SideNavItem that is not itself a SideNavItem sits at the nav list edge, because the nested group adds no inset; no supported setting insets it. |
+| Builder impact | No new prop; `resizable` also takes useResizable's output (FR7). Icons and labels keep their per-depth position. Content placed directly inside a SideNavItem that is not itself a SideNavItem sits at the nav list edge, because the nested group adds no inset; no supported setting insets it. |
| Compatibility/readiness | Nonbreaking change of visual direction (`spec:AST-017/FR3`). The collapsed rail and top-level rows are unchanged. Authority is current; implementation may be staged. |
| Review checks | Reject a nested row narrower than a top-level row, an indent drawn outside the row, an indent area that shows feedback but does not activate, a clipped focus ring, or an indent in the flyout's top level. Also reject an animated rail width (FR6). |
| Governing rules | DD1 and FR3–FR6 below; `component:AppShell` FR17 for the content; `family:navigation-destinations` for destinations; `architecture:internationalization` for logical direction. |
@@ -70,8 +70,9 @@ a row's depth and its row padding.
Top-level rows and the collapsed rail are unchanged. The rail's
collapse and expand gain FR6's motion.
- Compatibility class: nonbreaking appearance change of visual direction under
- `spec:AST-017/FR3`; no public API, accepted input, or accessibility semantic
- changes.
+ `spec:AST-017/FR3`. `resizable` also takes useResizable's output (FR7), a
+ patch `[feat]`; no other public API, accepted input, or accessibility
+ semantic changes.
- Controlled/uncontrolled behavior: unchanged.
- Migration decision: none; there is no supported way to keep the inset look.
@@ -121,6 +122,7 @@ Consumer migration instructions belong in consumer docs and release notes.
| FR4 | The indent area MUST be part of the row's target: a click there, including a modified or middle click on a link row, does what the same click on the row's primary element does. When the row also carries an independent expand toggle or `actions`, the indent belongs to the primary link or button, and the toggle and actions keep their own activation at the row's trailing edge. | `component:SideNav/DEC-1`; `family:navigation-destinations` | Settled intent; implementation may be staged |
| FR5 | An expanded nested group MUST NOT clip its rows once it has finished opening, so a nested row's focus ring paints in full. The group MAY clip while collapsed and while it opens or closes. | `component:SideNav/DEC-1` | Settled intent; implementation may be staged |
| FR6 | Collapsing or expanding the rail plays one motion on one clock, `spec:AST-105` FR4's one shared motion, the theme's curve tokens, never a spring: layout snaps to the final rail width at once, with no width animation, and the rail reveals or covers its labels by a clip, its edge riding the main content's glide and its labels and collapsed icons fading on the same clock (`spec:AST-105` FR9's inline two-state form; the clip and the ride are the shared edge reveal's, and SideNav's own code keeps only the fades; the rail lends no pane to its resize controller, so a collapse plays once; implementation gap: the clip and the ride still run in SideNav's own rail motion code, not in the shared edge reveal): a collapse ends in the real collapsed state, icons only, and an expand ends in the real expanded state, with its labels. On a collapse or an expand the rail's old state is one transient copy, inert and hidden from assistive technology, removed when the motion ends. The main content moves on the same clock, as `component:AppShell` FR17 states. A change made before the rail first paints lands at once, unless the rail's own toggle made it, so the initial state never animates. Under reduced motion the rail follows `design:reduced-motion` DR2 under a coarse primary pointer and DR3 otherwise. Collapsing or expanding keeps the nav and its items mounted, a `resizable` rail included, so item state such as an open nested group, and focus, persist (AR3 governs focus on what the collapsed rail does not render). | `component:SideNav/DEC-2`; `component:AppShell` FR17; `spec:AST-105` FR8, FR9 | Verified by `SideNavRailMotion.test.tsx` and `SideNavRailMotion.interaction.browser.spec.ts`; the `reducedMotion: 'always'` theme case pending |
+| FR7 | `resizable` also takes useResizable's output, beside `true` and its config object (owner decision, 2026-11-04); the region's `autoSaveId` then saves the width. When `resizable` is the hook's output and `collapsible` sets its own collapsed state, the region wins: the rail follows the region's `isCollapsed`, the toggle calls its `collapse` and `expand`, and SideNav warns in development, as `resizable`'s keys already override `collapsible`'s. When `resizable` is the hook's output, the region's own `collapsible` option also decides whether the rail collapses; a collapsible prop that disagrees is ignored, with the same warning. | `component:Resizable` FR9 | Verified by `resizableSlot.test.tsx` |
### Allowed variation
@@ -219,6 +221,7 @@ Consumer migration instructions belong in consumer docs and release notes.
| AR1 | Accessibility-tree comparison of nested rows | Plain, split, and actions rows | Any change in role, name, state, or order fails. | `audit:SideNav/a11y` |
| FR6 | `SideNavRailMotion.test.tsx`; `SideNavRailMotion.interaction.browser.spec.ts` frames of a collapse and an expand under a finger and a mouse, and of a second click after a long task (the `reducedMotion: 'always'` theme case pending); `SideNav.test.tsx` "keeps its items mounted across a collapse" suite | collapse and expand; a mouse and a finger; Reduce Motion under a coarse and under a fine primary pointer; a theme with `reducedMotion: 'always'`; a second toggle while the motion runs, and one after a long task; an app's change before the rail's first paint and after it, and the rail's own toggle before it; an open nested group across a collapse and an expand, plain and `resizable` | an animated width, a collapse that does not end icons-only, an expand that does not end with its labels, a rail copy reachable by assistive technology or left after the motion ends, a reversal whose fades start behind its edge, travel under Reduce Motion, anything but an opacity-only change of 200 ms or less under Reduce Motion on a coarse pointer (DR2), or any animated property under Reduce Motion on a fine pointer (DR3) fails A nested group that closes, or item state that resets, across a collapse and an expand fails. | `audit:SideNav/behavior` |
| AR3 | `SideNav.test.tsx` collapse-focus suite; `SideNavCollapseFocus.a11y.chromium.spec.ts`; gap: a rail with no collapse toggle and no row it keeps has no destination, so focus there still falls to the page | Focus on an icon-less row with and without a toggle, also in a resizable SideNav; a nested row and a kept row's action without a toggle; a kept row's replaced link with a toggle; a footer control, the toggle and Enter on the resize handle in a resizable SideNav; an icon-less heading link the collapse removes; a heading link whose subheading the collapsed rail drops, in a resizable SideNav; focus outside the nav; focus on a row the rail keeps; expanding | Focus left on the page body, a move to anything but the kept row's own control, the same header or footer control under the same name, the in-nav toggle, or the nearest kept row (preceding first; the first from a removed header or footer control or the resize handle), or a move of focus the collapse did not take, fails. | `audit:SideNav/a11y` |
+| FR7 | `resizableSlot.test.tsx` SideNav cases | The region alone; the region with `collapsible` setting `isCollapsed`, or disagreeing with the region's own `collapsible` | A rail that ignores the region's width, collapse state or `collapsible` option, a toggle that misses the region, or no development warning fails | `audit:SideNav/behavior` |
## Decision log
diff --git a/packages/core/api-report.md b/packages/core/api-report.md
index 496618e75866b..a6acd9e346a25 100644
--- a/packages/core/api-report.md
+++ b/packages/core/api-report.md
@@ -1453,7 +1453,7 @@ component LayoutPanel(props: LayoutPanelProps)
prop label?: string
prop padding?: SpacingStep
prop ref?: React.Ref
- prop resizable?: ResizableProps
+ prop resizable?: ResizableRegion | ResizableProps
prop role?: AriaRole
prop width?: SizeValue
prop xstyle?: StyleXStyles
@@ -1932,7 +1932,7 @@ component ResizeHandle(props: ResizeHandleProps)
prop pillPlacement?: PillPlacement = 'auto'
prop position?: 'inline' | 'overlay' = 'inline'
prop ref?: React.Ref
- prop resizable?: ResizableProps
+ prop resizable?: ResizableRegion | ResizableProps
prop xstyle?: StyleXStyles
inherits AriaAttributes, DOMAttributes, HTMLAttributes
component ScrollableArea(props: ScrollableAreaProps)
@@ -2076,7 +2076,7 @@ component SideNav(props: SideNavProps)
prop handleRef?: React.Ref @deprecated
prop header?: ReactNode
prop ref?: React.Ref
- prop resizable?: boolean | ResizableConfig = false
+ prop resizable?: boolean | ResizableConfig | ResizableRegion = false
prop style?: React.CSSProperties
prop topContent?: ReactNode
prop xstyle?: StyleXStyles
diff --git a/packages/core/src/Layout/LayoutPanel.doc.mjs b/packages/core/src/Layout/LayoutPanel.doc.mjs
index 9b269f4e1567a..8496c0b0a3d21 100644
--- a/packages/core/src/Layout/LayoutPanel.doc.mjs
+++ b/packages/core/src/Layout/LayoutPanel.doc.mjs
@@ -65,13 +65,13 @@ export const docs = {
name: 'width',
type: 'number | string',
description:
- 'Width of the panel. Numbers are treated as pixels, strings are used as-is. Ignored when resizable is provided; the hook controls width.',
+ "Width of the panel. Numbers are treated as pixels, strings are used as-is. Ignored when resizable is provided; the hook controls width. Beside a ResizeHandle given a useResizable region's props, a panel at width={region.size} is inert while the region is collapsed, and focus inside it moves to the handle as it collapses.",
},
{
name: 'resizable',
- type: 'ResizableProps',
+ type: 'ResizableRegion | ResizableProps',
description:
- "Resize props from useResizable(). When provided, the hook drives the panel width and a ResizeHandle should be placed adjacent to the panel. Carries the region's axis ('horizontal' | 'vertical'), which must match the adjacent ResizeHandle's direction.",
+ "The output of useResizable(), or its props. When provided, the hook drives the panel width and a ResizeHandle should be placed adjacent to the panel. Carries the region's axis ('horizontal' | 'vertical'), which must match the adjacent ResizeHandle's direction. Focus inside the panel moves to its ResizeHandle as it collapses, and the panel is inert while collapsed.",
},
],
};
@@ -119,13 +119,13 @@ export const docsZh = {
name: 'width',
type: 'number | string',
description:
- '面板宽度。数字按像素处理,字符串按原样使用。提供 resizable 时忽略,宽度由 hook 控制。',
+ '面板宽度。数字按像素处理,字符串按原样使用。提供 resizable 时忽略,宽度由 hook 控制。放在接收 useResizable 区域 props 的 ResizeHandle 旁边、以 width={region.size} 渲染时,区域折叠期间面板为 inert,折叠时面板内的焦点移到手柄上。',
},
{
name: 'resizable',
- type: 'ResizableProps',
+ type: 'ResizableRegion | ResizableProps',
description:
- "来自 useResizable() 的调整大小属性。提供时面板宽度由 hook 驱动,应在面板旁放置 ResizeHandle。其中携带区域的轴向('horizontal' | 'vertical'),必须与相邻 ResizeHandle 的 direction 一致。",
+ "useResizable() 的返回值或其 props。提供时面板宽度由 hook 驱动,应在面板旁放置 ResizeHandle。其中携带区域的轴向('horizontal' | 'vertical'),必须与相邻 ResizeHandle 的 direction 一致。折叠时面板内的焦点会移到其 ResizeHandle 上,折叠期间面板为 inert。",
},
],
};
@@ -145,8 +145,8 @@ export const docsDense = {
'Accessible label for landmark element; also names the region while it scrolls (default "Content").',
role: 'ARIA landmark role.',
width:
- 'Panel width. Numbers = pixels, strings as-is. Ignored when resizable provided; hook controls width.',
+ 'Panel width. Numbers = pixels, strings as-is. Ignored when resizable provided; hook controls width. width={region.size} beside a ResizeHandle given the region props: inert while collapsed; focus inside moves to the handle on collapse.',
resizable:
- 'Resize props from useResizable(). Hook drives panel width; place ResizeHandle adjacent.',
+ 'useResizable() output or its props. Hook drives panel width; place ResizeHandle adjacent. Focus inside moves to the handle on collapse; inert while collapsed.',
},
};
diff --git a/packages/core/src/Layout/LayoutPanel.tsx b/packages/core/src/Layout/LayoutPanel.tsx
index 89373b620175f..f0949447715f2 100644
--- a/packages/core/src/Layout/LayoutPanel.tsx
+++ b/packages/core/src/Layout/LayoutPanel.tsx
@@ -5,7 +5,7 @@
/**
* @file LayoutPanel.tsx
* @input Uses React StyleX, LayoutAreaContext, LayoutSlotsContext,
- * useScrollRegionProps
+ * useScrollRegionProps, resizablePane.ts
* @output Exports LayoutPanel component and LayoutPanelProps
* @position Sidebar panel for Layout start/end slots. Use for navigation panels,
* settings sidebars, detail panels, or any fixed-width side content.
@@ -18,7 +18,7 @@
import type {AriaRole, ReactNode} from 'react';
import type {BaseProps} from '../BaseProps';
-import {use} from 'react';
+import {use, useCallback, useRef, useState, useSyncExternalStore} from 'react';
import * as stylex from '@stylexjs/stylex';
import {colorVars, spacingVars} from '../theme/tokens.stylex';
import {LayoutAreaContext} from './LayoutAreaContext';
@@ -27,7 +27,19 @@ import {useMergedRefs} from '../hooks/useMergedRefs';
import {useScrollRegionProps} from '../hooks/useScrollRegionProps';
import {mergeProps} from '../utils';
import type {SizeValue, SpacingStep} from '../utils/types';
-import type {ResizableProps} from '../Resizable/useResizable';
+import {
+ resizablePropsOf,
+ type ResizableProps,
+ type ResizableRegion,
+} from '../Resizable/useResizable';
+import {
+ createResizablePaneLink,
+ resizeHandleBeside,
+} from '../Resizable/resizablePane';
+import {LayoutChangeSnapshot} from '../motion/LayoutChangeSnapshot';
+import {useIsomorphicLayoutEffect} from '../hooks/useIsomorphicLayoutEffect';
+import {moveFocusLeftBehind, noteFocusWithin} from '../utils/focusReturn';
+import {useFocusLeavingChange} from '../utils/useFocusLeavingChange';
import {themeProps} from '../utils/themeProps';
import {
paddingStyles,
@@ -103,6 +115,8 @@ const styles = stylex.create({
},
});
+const returnFalse = () => false;
+
// Dynamic styles for sizing props
const dynamicStyles = stylex.create({
sizing: (width: SizeValue | null) => ({
@@ -166,20 +180,20 @@ export interface LayoutPanelProps extends BaseProps {
width?: SizeValue;
/**
- * Resize props from `useResizable()`. When provided, the panel width
- * is driven by the hook and a resize handle should be placed adjacent
- * to this panel.
+ * The output of `useResizable()`, or its `props`. When provided, the panel
+ * width is driven by the hook and a resize handle should be placed
+ * adjacent to this panel.
*
* @example
* ```
- * const sidebar = useResizable({ defaultSize: 250, minSize: 200 });
- *
+ * const resizable = useResizable({ defaultSize: 250, minSize: 200 });
+ *
*
*
- *
+ *
* ```
*/
- resizable?: ResizableProps;
+ resizable?: ResizableRegion | ResizableProps;
}
/**
@@ -218,19 +232,76 @@ export function LayoutPanel({
padding,
role,
width,
- resizable,
+ resizable: resizableValue,
xstyle,
className,
style,
ref,
...props
}: LayoutPanelProps) {
+ const resizable =
+ resizableValue == null ? undefined : resizablePropsOf(resizableValue);
const area = use(LayoutAreaContext);
const {hasHeader, hasFooter} = use(LayoutSlotsContext);
// When resizable props are provided, use the hook-driven size
const effectiveWidth = resizable ? resizable._size : width;
+ // With no `resizable`, a ResizeHandle beside the panel that has a region's
+ // props finds the panel and holds it collapsed with the region. The panel
+ // lends its element in its callback ref for that.
+ const [paneLink] = useState(createResizablePaneLink);
+ const isHeldCollapsed = useSyncExternalStore(
+ paneLink.subscribe,
+ paneLink.isCollapsed,
+ returnFalse,
+ );
+ const isLentBeside = resizable == null;
+ const lendRef = useCallback(
+ (element: HTMLDivElement | null) => {
+ paneLink.attach(isLentBeside ? element : null);
+ },
+ [paneLink, isLentBeside],
+ );
+
+ // A collapsing panel moves focus inside it to its ResizeHandle, through the
+ // focus-leaving sequence that disclosures share. It goes inert and hidden
+ // from assistive technology a commit later, so inert never drops that focus
+ // to the page. Opening drops both at once. The ResizeHandle beside it holds
+ // the region's collapse state, or else the `resizable` props hold it.
+ const elementRef = useRef(null);
+ const isRegionCollapsed =
+ resizable == null ? isHeldCollapsed : resizable._isCollapsed;
+ const noteFocusBeforeCollapse = useFocusLeavingChange(
+ isRegionCollapsed,
+ (wasCollapsed: boolean) =>
+ wasCollapsed ? null : noteFocusWithin(elementRef.current),
+ (noted, isCollapsed) => {
+ const panel = elementRef.current;
+ if (isCollapsed && panel != null) {
+ moveFocusLeftBehind(
+ noted,
+ paneLink.focusTarget() ?? resizeHandleBeside(panel, area),
+ panel,
+ );
+ }
+ },
+ );
+ const [isSettledCollapsed, setIsSettledCollapsed] =
+ useState(isRegionCollapsed);
+ if (!isRegionCollapsed && isSettledCollapsed) {
+ setIsSettledCollapsed(false);
+ }
+ // Syncs `inert` with the browser's focus: the panel goes inert only after
+ // this commit's focus move.
+ useIsomorphicLayoutEffect(() => {
+ if (isRegionCollapsed && !isSettledCollapsed) {
+ // eslint-disable-next-line @eslint-react/set-state-in-effect -- the panel goes inert only after the focus-leaving move above ran in this commit; inert in the collapsing commit would drop that focus to the page first
+ setIsSettledCollapsed(true);
+ }
+ }, [isRegionCollapsed, isSettledCollapsed]);
+ const isConcealed = isRegionCollapsed && isSettledCollapsed;
+
// Determine panel position
const isStartPanel = area === 'start';
const isEndPanel = area === 'end';
@@ -241,7 +312,7 @@ export function LayoutPanel({
const shouldCollapseSpacing =
!hasDivider && !isZeroPadding && padding == null;
const region = useScrollRegionProps({isScrollable, role, label});
- const mergedRef = useMergedRefs(ref, region.ref);
+ const mergedRef = useMergedRefs(ref, region.ref, elementRef, lendRef);
// Select divider style based on position
const dividerStyle = isStartPanel
@@ -288,7 +359,12 @@ export function LayoutPanel({
className,
style,
)}
- {...props}>
+ {...props}
+ {...(isConcealed ? {inert: true, 'aria-hidden': true} : {})}>
+
{children}
);
diff --git a/packages/core/src/Resizable/Resizable.doc.mjs b/packages/core/src/Resizable/Resizable.doc.mjs
index c1408e20a1e5a..cfea5f238d0de 100644
--- a/packages/core/src/Resizable/Resizable.doc.mjs
+++ b/packages/core/src/Resizable/Resizable.doc.mjs
@@ -45,7 +45,7 @@ export const docs = {
description:
'Hook-based resizable panel system. useResizable() manages size state ' +
'and ResizeHandle provides the interactive pill-grip separator. ' +
- 'Pass resize props to existing layout components via their resizable prop.',
+ "Pass the hook's output to existing layout components via their resizable prop.",
bestPractices: [
{
guidance: true,
@@ -56,7 +56,7 @@ export const docs = {
guidance: true,
description:
'Use useResizable() with existing Astryx layout components. ' +
- 'Pass the returned props to the resizable prop on LayoutPanel or SideNav.',
+ 'Pass its output to the resizable prop on LayoutPanel, ResizeHandle or SideNav; any other element can use its size.',
},
{
guidance: true,
@@ -88,7 +88,7 @@ export const docs = {
examples: [
{
label: 'Structured fixed pixels',
- code: `const region = useResizable({
+ code: `const resizable = useResizable({
defaultSize: pixel(333),
containerRef,
});`,
@@ -101,16 +101,44 @@ import {percent, pixel} from '@astryxdesign/core/Resizable/utils';
const containerRef = useRef(null);
-const region = useResizable({
+const resizable = useResizable({
defaultSize: percent(40, {min: pixel(333)}),
containerRef,
});
-;`,
+;`,
+ },
+ {
+ label: 'Any element, sized by the hook',
+ code: `'use client';
+
+import * as stylex from '@stylexjs/stylex';
+import {Card} from '@astryxdesign/core/Card';
+import {ResizeHandle, useResizable} from '@astryxdesign/core/Resizable';
+import {Stack} from '@astryxdesign/core/Stack';
+import {Text} from '@astryxdesign/core/Text';
+
+const styles = stylex.create({
+ // The hook's size as a dynamic style; the card keeps it in the row.
+ preview: (width: number) => ({width, flexShrink: 0}),
+});
+
+export default function InvoicePreviewSplit() {
+ const resizable = useResizable({defaultSize: 320, minSize: 240, maxSize: 480});
+ return (
+
+
+ Invoice INV-2041, $1,280.00, due Nov 18
+
+
+ Line items, payments and notes for the selected invoice.
+
+ );
+}`,
},
{
label: 'Live percentage floor',
- code: `const region = useResizable({
+ code: `const resizable = useResizable({
defaultSize: 0,
minSize: percent(40, {min: pixel(333)}),
containerRef,
@@ -118,7 +146,7 @@ const region = useResizable({
},
{
label: 'Live percentage ceiling',
- code: `const region = useResizable({
+ code: `const resizable = useResizable({
defaultSize: 500,
maxSize: percent(10, {max: pixel(400)}),
containerRef,
@@ -236,9 +264,9 @@ const region = useResizable({
},
{
name: 'resizable',
- type: 'ResizableProps',
+ type: 'ResizableRegion | ResizableProps',
description:
- "Resize props from useResizable: connects handle to panel. Carries the region's axis ('horizontal' | 'vertical'), which must match this handle's direction.",
+ "The output of useResizable, or its props: connects handle to panel. Carries the region's axis ('horizontal' | 'vertical'), which must match this handle's direction.",
required: true,
},
{
@@ -265,12 +293,12 @@ export const docsDense = {
usage: {
anatomy,
description:
- 'Hook-based resizable panel system. useResizable() manages size state; ResizeHandle provides interactive pill-grip separator. Pass resize props to existing layout components via their resizable prop.',
+ 'Hook-based resizable panel system. useResizable() manages size state; ResizeHandle provides interactive pill-grip separator. Pass the hook output to existing layout components via their resizable prop.',
bestPractices: [
{
guidance: true,
description:
- 'Use useResizable() w/ existing Astryx layout components. Pass returned props to resizable prop on LayoutPanel or SideNav.',
+ 'Use useResizable() w/ existing Astryx layout components. Pass its output to resizable prop on LayoutPanel, ResizeHandle or SideNav.',
},
{
guidance: true,
@@ -320,7 +348,7 @@ export const docsDense = {
pillPlacement:
'which side of divider pill sits on. auto = content side (derived from isReversed), flips when collapsed; start = left/top, end = right/bottom, center = centered on divider. Moves only the pill (still draggable); grab zone stays on the divider',
label: 'accessible label for separator',
- resizable: 'resize props from useResizable: connects handle to panel',
+ resizable: 'useResizable output or its props: connects handle to panel',
},
},
],
diff --git a/packages/core/src/Resizable/ResizeHandle.tsx b/packages/core/src/Resizable/ResizeHandle.tsx
index f73da1158c6ed..ebd3705e016be 100644
--- a/packages/core/src/Resizable/ResizeHandle.tsx
+++ b/packages/core/src/Resizable/ResizeHandle.tsx
@@ -12,7 +12,7 @@
* embedded frame still arrive.
*/
-import {useEffect, useRef, useState} from 'react';
+import {useCallback, useEffect, useReducer, useRef, useState} from 'react';
import type {HTMLAttributes, ReactNode} from 'react';
import * as stylex from '@stylexjs/stylex';
import type {BaseProps} from '../BaseProps';
@@ -28,18 +28,29 @@ import {mergeProps, rtlStyles} from '../utils';
import {
getCollapseControls,
getEffectiveMaxPx,
+ resizablePropsOf,
type ResizableProps,
+ type ResizableRegion,
} from './useResizable';
import {
createResizeGesture,
type ResizeGesture,
type ResizeGestureModel,
} from './resizeGesture';
+import {
+ handOverPane,
+ registerResizeHandle,
+ resizablePaneFor,
+ watchResizablePanes,
+ type ResizablePane,
+} from './resizablePane';
import {separatorAttributes} from './separatorAttributes';
import {themeProps} from '../utils/themeProps';
import {useDevWarning} from '../hooks/useDevWarning';
+import {useIsomorphicLayoutEffect} from '../hooks/useIsomorphicLayoutEffect';
import {useMergedRefs} from '../hooks/useMergedRefs';
+import {useInteractionModalityTracking} from '../utils/interactionModality';
import {
registerEnglishFallback,
useComponentTranslator,
@@ -369,8 +380,8 @@ export interface ResizeHandleProps extends Omit<
*/
label?: string;
- /** Resize props from useResizable region. */
- resizable?: ResizableProps;
+ /** The output of `useResizable()`, or its `props`. */
+ resizable?: ResizableRegion | ResizableProps;
/** Custom handle content. Overrides the default pill. */
children?: ReactNode;
@@ -386,7 +397,7 @@ export interface ResizeHandleProps extends Omit<
* @example
* ```
*
* ```
@@ -401,13 +412,15 @@ export function ResizeHandle({
isAlwaysVisible = true,
pillPlacement = 'auto',
label: labelFromProps,
- resizable,
+ resizable: resizableValue,
children,
xstyle,
className,
ref,
...props
}: ResizeHandleProps) {
+ const resizable =
+ resizableValue == null ? undefined : resizablePropsOf(resizableValue);
const t = useComponentTranslator();
const label = labelFromProps ?? t('@astryx.resizable.handle.label');
const handleRef = useRef(null);
@@ -529,6 +542,55 @@ export function ResizeHandle({
};
}, [hasRegion, regionGesture]);
+ // --- The LayoutPanel beside a handle given `resizable` props ---
+
+ // A collapsing panel moves focus here, and the ring follows the last input.
+ const {ref: modalityRef} = useInteractionModalityTracking();
+ // A LayoutPanel at the region's size, with no `resizable` prop, has no
+ // controller to find it. This handle holds it collapsed with the region, so
+ // focus inside it moves here and it goes inert. A panel lent later is found
+ // at once.
+ const [, rerender] = useReducer((count: number) => count + 1, 0);
+ const heldPaneRef = useRef(null);
+ // The element goes in the handle registry, for a collapsing panel beside it
+ // to hand focus to. When the element goes, a held panel opens.
+ const registryRef = useCallback((element: HTMLDivElement | null) => {
+ if (element == null) {
+ return;
+ }
+ const unregister = registerResizeHandle(element);
+ return () => {
+ unregister();
+ heldPaneRef.current = handOverPane(
+ heldPaneRef.current,
+ null,
+ false,
+ null,
+ );
+ };
+ }, []);
+ const isRegionCollapsed = resizable?._isCollapsed ?? false;
+ // Syncs with the lent-panel registry. A panel lent or taken back re-renders
+ // this handle, so it looks for its pane again.
+ useEffect(
+ () => (hasRegion ? watchResizablePanes(rerender) : undefined),
+ [hasRegion],
+ );
+ // Syncs the held panel with the region's collapse state after each commit.
+ useIsomorphicLayoutEffect(() => {
+ const element = handleRef.current;
+ const pane =
+ hasRegion && element != null
+ ? resizablePaneFor(element, isReversed)
+ : null;
+ heldPaneRef.current = handOverPane(
+ heldPaneRef.current,
+ pane,
+ isRegionCollapsed,
+ element,
+ );
+ });
+
// --- ARIA ---
const sizeNow = resizable?._size;
const minNow = resizable?._minSizePx;
@@ -587,7 +649,7 @@ export function ResizeHandle({
return (
+
+
+ ) : (
+
+
+
+ );
+ const handle = (
+
+ );
+ return (
+ <>
+
+
+ {panel}
+ {handle}
+ >
+ )
+ }
+ content={Main}
+ end={
+ isReversed ? (
+ <>
+ {handle}
+ {panel}
+ >
+ ) : undefined
+ }
+ />
+ >
+ );
+}
+
+const pane = () => screen.getByTestId('pane');
+const separator = () => screen.getByRole('separator', {name: 'Resize pane'});
+
+beforeEach(() => {
+ __resetInteractionModalityForTest();
+ propsRegion = undefined;
+ localStorage.clear();
+});
+
+afterEach(() => {
+ document.body.style.cursor = '';
+ document.body.style.userSelect = '';
+});
+
+describe('focus leaves a collapsing pane, and the collapsed pane is inert (component:LayoutPanel AR2)', () => {
+ it('moves focus inside a collapsing pane to its ResizeHandle and makes the pane inert and hidden until it opens', () => {
+ render();
+ screen.getByRole('button', {name: 'Inside'}).focus();
+ act(() => {
+ propsRegion?.collapse();
+ });
+ expect(document.activeElement).toBe(separator());
+ expect(pane()).toHaveAttribute('inert');
+ expect(pane()).toHaveAttribute('aria-hidden', 'true');
+ act(() => {
+ propsRegion?.expand();
+ });
+ // Opening moves no focus.
+ expect(document.activeElement).toBe(separator());
+ expect(pane()).not.toHaveAttribute('inert');
+ expect(pane()).not.toHaveAttribute('aria-hidden');
+ });
+
+ it('leaves focus where it is for a collapse started from the handle or from outside the pane', () => {
+ render();
+ separator().focus();
+ fireEvent.keyDown(separator(), {key: 'Enter'});
+ expect(document.activeElement).toBe(separator());
+ fireEvent.keyDown(separator(), {key: 'Enter'});
+ const outside = screen.getByRole('button', {name: 'Outside'});
+ outside.focus();
+ act(() => {
+ propsRegion?.collapse();
+ });
+ expect(document.activeElement).toBe(outside);
+ expect(pane()).toHaveAttribute('inert');
+ });
+
+ it('keeps a pane that starts collapsed inert from its first commit', () => {
+ render();
+ expect(pane()).toHaveAttribute('inert');
+ expect(pane()).toHaveAttribute('aria-hidden', 'true');
+ });
+
+ it("moves focus to the adjacent handle and then makes the pane inert for the hook's props and for a hand-built object", () => {
+ for (const isHandBuilt of [false, true]) {
+ for (const isReversed of [false, true]) {
+ const view = render(
+ ,
+ );
+ screen.getByRole('button', {name: 'Inside'}).focus();
+ act(() => {
+ propsRegion?.collapse();
+ });
+ expect(document.activeElement).toBe(separator());
+ expect(pane()).toHaveAttribute('inert');
+ expect(pane()).toHaveAttribute('aria-hidden', 'true');
+ act(() => {
+ propsRegion?.expand();
+ });
+ expect(document.activeElement).toBe(separator());
+ expect(pane()).not.toHaveAttribute('inert');
+ expect(pane()).not.toHaveAttribute('aria-hidden');
+ view.unmount();
+ }
+ }
+ });
+
+ it("keeps a pane that takes the hook's props, or a hand-built object, inert from its first commit when the region starts collapsed", () => {
+ for (const isHandBuilt of [false, true]) {
+ const view = render(
+ ,
+ );
+ expect(pane()).toHaveAttribute('inert');
+ expect(pane()).toHaveAttribute('aria-hidden', 'true');
+ view.unmount();
+ }
+ });
+
+ it("moves focus to the handle and makes the pane inert for a pane at the hook's size beside a handle given its props", () => {
+ for (const isReversed of [false, true]) {
+ const view = render();
+ screen.getByRole('button', {name: 'Inside'}).focus();
+ act(() => {
+ propsRegion?.collapse();
+ });
+ expect(document.activeElement).toBe(separator());
+ expect(pane()).toHaveAttribute('inert');
+ expect(pane()).toHaveAttribute('aria-hidden', 'true');
+ act(() => {
+ propsRegion?.expand();
+ });
+ expect(pane()).not.toHaveAttribute('inert');
+ expect(pane()).not.toHaveAttribute('aria-hidden');
+ view.unmount();
+ }
+ });
+
+ it("keeps a pane at the hook's size, beside a handle given its props, inert from its first commit when the region starts collapsed", () => {
+ render();
+ expect(pane()).toHaveAttribute('inert');
+ expect(pane()).toHaveAttribute('aria-hidden', 'true');
+ });
+
+ it('keeps an end pane, reversed by its ResizeHandle, inert from its first commit when the region starts collapsed', () => {
+ render();
+ expect(pane()).toHaveAttribute('inert');
+ expect(pane()).toHaveAttribute('aria-hidden', 'true');
+ act(() => {
+ propsRegion?.expand();
+ });
+ expect(pane()).not.toHaveAttribute('inert');
+ });
+
+ for (const input of ['pointer', 'keyboard'] as const) {
+ it(`moves focus to the handle with the ring the last input calls for (${input}; architecture:interaction-modality INV1)`, () => {
+ render();
+ const inside = screen.getByRole('button', {name: 'Inside'});
+ inside.focus();
+ if (input === 'pointer') {
+ fireEvent.pointerDown(inside);
+ } else {
+ fireEvent.keyDown(inside, {key: 'Enter'});
+ }
+ const focus = vi.spyOn(separator(), 'focus');
+ act(() => {
+ propsRegion?.collapse();
+ });
+ expect(document.activeElement).toBe(separator());
+ expect(focus.mock.calls).toEqual(
+ input === 'pointer' ? [[{focusVisible: false}]] : [[]],
+ );
+ });
+ }
+
+ it('never makes a LayoutPanel without a resize controller inert', () => {
+ render(
+
+
+
+ }
+ content={Main}
+ />,
+ );
+ expect(pane()).not.toHaveAttribute('inert');
+ expect(pane()).not.toHaveAttribute('aria-hidden');
+ });
+});
diff --git a/packages/core/src/Resizable/resizablePane.ts b/packages/core/src/Resizable/resizablePane.ts
new file mode 100644
index 0000000000000..18beea58ea502
--- /dev/null
+++ b/packages/core/src/Resizable/resizablePane.ts
@@ -0,0 +1,188 @@
+// Copyright (c) Meta Platforms, Inc. and affiliates.
+
+/**
+ * @file resizablePane.ts
+ * @input A LayoutPanel's element, and the collapse state the ResizeHandle
+ * beside it reports
+ * @output The internal channel through which a LayoutPanel at a region's size
+ * lends its element to the ResizeHandle beside it, and the ResizeHandle a
+ * collapsing panel hands focus to
+ * @position Internal to Resizable and Layout; no entry point exports it. No
+ * React, and no DOM work on import
+ */
+
+/** What a LayoutPanel lends the ResizeHandle beside it. */
+export interface ResizablePane {
+ readonly element: HTMLElement;
+ /**
+ * The handle says whether its region is collapsed, and which handle focus
+ * inside the panel moves to as it collapses. The panel is then inert and
+ * hidden from assistive technology while it is collapsed.
+ */
+ setCollapsed(isCollapsed: boolean, focusTarget: HTMLElement | null): void;
+}
+
+const lent = new WeakMap();
+const watchers = new Set<() => void>();
+// The elements ResizeHandle renders, for a panel beside one to find it.
+const resizeHandles = new WeakSet();
+
+/** ResizeHandle registers the element it renders; returns the unregister. */
+export function registerResizeHandle(element: Element): () => void {
+ resizeHandles.add(element);
+ return () => {
+ resizeHandles.delete(element);
+ };
+}
+
+function notifyWatchers(): void {
+ for (const watcher of [...watchers]) {
+ watcher();
+ }
+}
+
+/**
+ * Calls `listener` whenever a panel is lent beside a handle or taken back, so
+ * a handle looks for its pane again. Returns the function that stops it.
+ */
+export function watchResizablePanes(listener: () => void): () => void {
+ watchers.add(listener);
+ return () => {
+ watchers.delete(listener);
+ };
+}
+
+/**
+ * The pane a resize handle resizes: the lent LayoutPanel just before it, or
+ * just after it when the region is reversed.
+ */
+export function resizablePaneBeside(
+ handle: Element,
+ side: 'before' | 'after',
+): ResizablePane | null {
+ const sibling =
+ side === 'before'
+ ? handle.previousElementSibling
+ : handle.nextElementSibling;
+ return sibling == null ? null : (lent.get(sibling) ?? null);
+}
+
+/** The lent panel a handle sizes: before it, or after it when reversed. */
+export function resizablePaneFor(
+ handle: Element,
+ isReversed: boolean,
+): ResizablePane | null {
+ return resizablePaneBeside(handle, isReversed ? 'after' : 'before');
+}
+
+/**
+ * Moves a handle's hold from `held` to `next`, and returns `next`. A pane the
+ * handle no longer holds opens. The held pane takes the region's collapse
+ * state, and focus inside it moves to `handle` as it collapses.
+ */
+export function handOverPane(
+ held: ResizablePane | null,
+ next: ResizablePane | null,
+ isCollapsed: boolean,
+ handle: HTMLElement | null,
+): ResizablePane | null {
+ if (next !== held) {
+ held?.setCollapsed(false, null);
+ }
+ next?.setCollapsed(isCollapsed, handle);
+ return next;
+}
+
+/**
+ * The ResizeHandle beside a panel, on the side that faces the content. It is
+ * after a start panel, before an end panel, and on either side elsewhere.
+ */
+export function resizeHandleBeside(
+ panel: Element,
+ area: string | null | undefined,
+): HTMLElement | null {
+ const candidates =
+ area === 'start'
+ ? [panel.nextElementSibling]
+ : area === 'end'
+ ? [panel.previousElementSibling]
+ : [panel.nextElementSibling, panel.previousElementSibling];
+ for (const candidate of candidates) {
+ if (candidate instanceof HTMLElement && resizeHandles.has(candidate)) {
+ return candidate;
+ }
+ }
+ return null;
+}
+
+/** LayoutPanel's side of the channel, one per panel. */
+export interface ResizablePaneLink {
+ /** For useSyncExternalStore: whether a handle holds the panel collapsed. */
+ readonly subscribe: (listener: () => void) => () => void;
+ readonly isCollapsed: () => boolean;
+ /** The handle that named itself for focus to move to, if any. */
+ focusTarget(): HTMLElement | null;
+ /**
+ * From the panel's callback ref: its element, lent beside its handle for
+ * the handle to find. Null takes it back.
+ */
+ attach(element: HTMLElement | null): void;
+}
+
+export function createResizablePaneLink(): ResizablePaneLink {
+ let isCollapsed = false;
+ let focusTarget: HTMLElement | null = null;
+ let attached: ResizablePane | null = null;
+ const listeners = new Set<() => void>();
+
+ const setCollapsed = (next: boolean) => {
+ if (next === isCollapsed) {
+ return;
+ }
+ isCollapsed = next;
+ for (const notify of [...listeners]) {
+ notify();
+ }
+ };
+
+ return {
+ subscribe(notify) {
+ listeners.add(notify);
+ return () => {
+ listeners.delete(notify);
+ };
+ },
+ isCollapsed: () => isCollapsed,
+ focusTarget: () => focusTarget,
+ attach(element) {
+ if (attached?.element === element) {
+ return;
+ }
+ const wasLent = attached != null;
+ if (attached != null && lent.get(attached.element) === attached) {
+ lent.delete(attached.element);
+ }
+ attached = null;
+ if (element == null) {
+ // Taken back: no handle holds it collapsed any more.
+ focusTarget = null;
+ setCollapsed(false);
+ } else {
+ const pane: ResizablePane = {
+ element,
+ setCollapsed(next, nextFocusTarget) {
+ if (attached === pane) {
+ focusTarget = nextFocusTarget;
+ setCollapsed(next);
+ }
+ },
+ };
+ attached = pane;
+ lent.set(element, pane);
+ }
+ if (wasLent || element != null) {
+ notifyWatchers();
+ }
+ },
+ };
+}
diff --git a/packages/core/src/Resizable/resizableSlot.test.tsx b/packages/core/src/Resizable/resizableSlot.test.tsx
new file mode 100644
index 0000000000000..575942a36a436
--- /dev/null
+++ b/packages/core/src/Resizable/resizableSlot.test.tsx
@@ -0,0 +1,238 @@
+// Copyright (c) Meta Platforms, Inc. and affiliates.
+
+/**
+ * @file resizableSlot.test.tsx
+ * @input vitest (jsdom), Testing Library, LayoutPanel, ResizeHandle and
+ * SideNav given useResizable's output in `resizable`
+ * @output Checks that each `resizable` slot takes the hook's output directly,
+ * as it takes the output's `props`, and that SideNav's rail follows a
+ * region it is given
+ * @position Unit tests for useResizable.ts, LayoutPanel.tsx,
+ * ResizeHandle.tsx and SideNav.tsx
+ */
+
+import {act, fireEvent, render, screen} from '@testing-library/react';
+import {afterEach, describe, expect, it, vi} from 'vitest';
+import {Layout} from '../Layout/Layout';
+import {LayoutContent} from '../Layout/LayoutContent';
+import {LayoutPanel} from '../Layout/LayoutPanel';
+import {SideNav} from '../SideNav/SideNav';
+import {SideNavItem} from '../SideNav/SideNavItem';
+import {SideNavSection} from '../SideNav/SideNavSection';
+import {ResizeHandle} from './ResizeHandle';
+import {
+ useResizable,
+ type ResizableRegion,
+ type UseResizableSingleConfig,
+} from './useResizable';
+
+function StubIcon() {
+ return ;
+}
+
+let latest: ResizableRegion | undefined;
+
+const NO_CONFIG: UseResizableSingleConfig = {};
+
+/** A start panel and its handle, given the region or its `props`. */
+function Split({
+ form,
+ config = NO_CONFIG,
+}: {
+ form: 'region' | 'props';
+ config?: UseResizableSingleConfig;
+}) {
+ const resizable = useResizable({
+ defaultSize: 200,
+ collapsible: true,
+ ...config,
+ });
+ latest = resizable;
+ const value = form === 'region' ? resizable : resizable.props;
+ return (
+
+
+
+
+
+ >
+ }
+ content={Main}
+ />
+ );
+}
+
+const pane = () => screen.getByTestId('pane');
+const separator = () => screen.getByRole('separator', {name: 'Resize pane'});
+
+afterEach(() => {
+ latest = undefined;
+ localStorage.clear();
+});
+
+describe("the `resizable` slot takes useResizable's output directly", () => {
+ for (const form of ['region', 'props'] as const) {
+ it(`sizes the LayoutPanel and drives the region from its ResizeHandle (${form})`, () => {
+ render();
+ expect(pane().getAttribute('style') ?? '').toContain('200px');
+ fireEvent.keyDown(separator(), {key: 'ArrowRight'});
+ expect(latest?.size).toBeGreaterThan(200);
+ expect(separator()).toHaveAttribute(
+ 'aria-valuenow',
+ String(latest?.size),
+ );
+ });
+
+ it(`collapses the panel, which goes inert, and opens it again (${form})`, () => {
+ render();
+ act(() => {
+ latest?.collapse();
+ });
+ expect(pane().getAttribute('style') ?? '').not.toContain('200px');
+ expect(pane()).toHaveAttribute('inert');
+ act(() => {
+ latest?.expand();
+ });
+ expect(pane().getAttribute('style') ?? '').toContain('200px');
+ expect(pane()).not.toHaveAttribute('inert');
+ });
+ }
+});
+
+/** A SideNav whose width and collapse state the caller's region owns. */
+function RegionNav({
+ collapsible,
+ isRegionCollapsible = true,
+}: {
+ collapsible?: boolean | {isCollapsed?: boolean};
+ isRegionCollapsible?: boolean;
+}) {
+ const resizable = useResizable({
+ defaultSize: 300,
+ minSize: 200,
+ maxSize: 400,
+ collapsible: isRegionCollapsible,
+ });
+ latest = resizable;
+ return (
+
+
+
+
+
+ );
+}
+
+describe("SideNav takes useResizable's output in `resizable`, and the region owns collapse", () => {
+ it("sizes the rail from the region and follows the region's collapse state", () => {
+ render();
+ expect(screen.getByTestId('nav').getAttribute('style') ?? '').toContain(
+ '300px',
+ );
+ act(() => {
+ latest?.collapse();
+ });
+ expect(latest?.isCollapsed).toBe(true);
+ expect(
+ screen.getByRole('button', {name: 'Expand sidebar'}),
+ ).toBeInTheDocument();
+ act(() => {
+ latest?.expand();
+ });
+ expect(
+ screen.getByRole('button', {name: 'Collapse sidebar'}),
+ ).toBeInTheDocument();
+ });
+
+ it("calls the region's collapse and expand from its toggle", () => {
+ render();
+ fireEvent.click(screen.getByRole('button', {name: 'Collapse sidebar'}));
+ expect(latest?.isCollapsed).toBe(true);
+ fireEvent.click(screen.getByRole('button', {name: 'Expand sidebar'}));
+ expect(latest?.isCollapsed).toBe(false);
+ });
+
+ it('warns in development when `collapsible` sets its own collapsed state, and the region wins', () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ render();
+ expect(latest?.isCollapsed).toBe(false);
+ expect(
+ screen.getByRole('button', {name: 'Collapse sidebar'}),
+ ).toBeInTheDocument();
+ expect(regionWarnings(warn)).toEqual([
+ expect.stringContaining('`collapsible` sets `isCollapsed`'),
+ ]);
+ warn.mockRestore();
+ });
+
+ it("lets the region's own `collapsible` option decide whether the rail collapses, and warns when `collapsible` disagrees", () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ const view = render();
+ expect(
+ screen.queryByRole('button', {name: 'Collapse sidebar'}),
+ ).not.toBeInTheDocument();
+ expect(regionWarnings(warn)).toEqual([
+ expect.stringContaining('the region is not collapsible'),
+ ]);
+ view.unmount();
+ warn.mockClear();
+ render();
+ expect(
+ screen.getByRole('button', {name: 'Collapse sidebar'}),
+ ).toBeInTheDocument();
+ expect(regionWarnings(warn)).toEqual([
+ expect.stringContaining('the region is collapsible'),
+ ]);
+ warn.mockRestore();
+ });
+
+ it('keeps the boolean and config forms as they were: `collapsible` turns collapse on beside them, and only collapse-state keys conflict, with `resizable` winning', () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ for (const resizable of [true, {defaultWidth: 300}]) {
+ const view = render(
+
+ Content
+ ,
+ );
+ expect(
+ screen.getByRole('button', {name: 'Collapse sidebar'}),
+ ).toBeInTheDocument();
+ view.unmount();
+ }
+ expect(warn).not.toHaveBeenCalled();
+ render(
+ {}}}
+ resizable={{isCollapsed: true, onCollapseChange: () => {}}}>
+ Content
+ ,
+ );
+ expect(
+ screen.getByRole('button', {name: 'Expand sidebar'}),
+ ).toBeInTheDocument();
+ expect(String(warn.mock.calls[0]?.[0])).toContain('resizable wins');
+ expect(regionWarnings(warn)).toEqual([]);
+ warn.mockRestore();
+ });
+
+ it('warns about nothing when `collapsible` agrees with the region or is left out', () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ const view = render();
+ view.unmount();
+ render();
+ expect(
+ screen.queryByRole('button', {name: 'Collapse sidebar'}),
+ ).not.toBeInTheDocument();
+ expect(regionWarnings(warn)).toEqual([]);
+ warn.mockRestore();
+ });
+});
+
+/** The development warnings SideNav gave about a region it was given. */
+function regionWarnings(warn: {mock: {calls: unknown[][]}}): string[] {
+ return warn.mock.calls
+ .map((call: unknown[]) => call.map(String).join(' '))
+ .filter((text: string) => text.includes("useResizable's output"));
+}
diff --git a/packages/core/src/Resizable/useResizable.ts b/packages/core/src/Resizable/useResizable.ts
index 9b65f62accf94..ff41d69de9f1c 100644
--- a/packages/core/src/Resizable/useResizable.ts
+++ b/packages/core/src/Resizable/useResizable.ts
@@ -338,6 +338,21 @@ const collapseControls = new WeakMap();
*/
const effectiveMaxima = new WeakMap();
+/**
+ * The props behind a component's `resizable` slot. It takes useResizable's
+ * output directly, and still takes that output's `props`.
+ */
+export function resizablePropsOf(
+ value: ResizableRegion | ResizableProps,
+): ResizableProps {
+ return 'props' in value ? value.props : value;
+}
+
+/** Whether a `resizable` value is useResizable's output for one region. */
+export function isResizableRegion(value: unknown): value is ResizableRegion {
+ return typeof value === 'object' && value !== null && 'props' in value;
+}
+
/** The reported maximum for `props`, or `undefined` for a hand-built object. */
export function getEffectiveMaxPx(props: ResizableProps): number | undefined {
return effectiveMaxima.get(props);
diff --git a/packages/core/src/SideNav/SideNav.doc.mjs b/packages/core/src/SideNav/SideNav.doc.mjs
index aeb192de53c22..573fada865a07 100644
--- a/packages/core/src/SideNav/SideNav.doc.mjs
+++ b/packages/core/src/SideNav/SideNav.doc.mjs
@@ -104,8 +104,8 @@ export const docs = {
},
{
name: 'resizable',
- type: 'boolean | { defaultWidth?: number; minWidth?: number; maxWidth?: number; autoSaveId?: string; onWidthChange?: (width: number) => void; defaultIsCollapsed?: boolean; isCollapsed?: boolean; onCollapseChange?: (isCollapsed: boolean) => void }',
- description: 'Enables a resize handle at the inline-end edge. true for defaults (260px initial, 180-480px range), or a ResizableConfig object (defaultWidth, minWidth, maxWidth, autoSaveId for localStorage persistence of width and collapse state, onWidthChange). It can also own collapse state (defaultIsCollapsed, isCollapsed + onCollapseChange); when both props carry collapse state, resizable wins and a dev warning names the conflicting keys. The handle is hidden while collapsed.',
+ type: 'boolean | { defaultWidth?: number; minWidth?: number; maxWidth?: number; autoSaveId?: string; onWidthChange?: (width: number) => void; defaultIsCollapsed?: boolean; isCollapsed?: boolean; onCollapseChange?: (isCollapsed: boolean) => void } | ResizableRegion',
+ description: 'Enables a resize handle at the inline-end edge. true for defaults (260px initial, 180-480px range), or a ResizableConfig object (defaultWidth, minWidth, maxWidth, autoSaveId for localStorage persistence of width and collapse state, onWidthChange). It can also own collapse state (defaultIsCollapsed, isCollapsed + onCollapseChange); when both props carry collapse state, resizable wins and a dev warning names the conflicting keys. It also takes the output of useResizable(), whose axis (\'horizontal\' | \'vertical\') must be \'horizontal\': that region then owns the width and the collapse state, so 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 also decides whether the rail collapses; a collapsible prop that disagrees, or the collapse state it sets, is ignored, with a dev warning. The handle is hidden while collapsed.',
default: 'false',
},
{
diff --git a/packages/core/src/SideNav/SideNav.tsx b/packages/core/src/SideNav/SideNav.tsx
index 4a07efcbe7a07..2d50e8ff154de 100644
--- a/packages/core/src/SideNav/SideNav.tsx
+++ b/packages/core/src/SideNav/SideNav.tsx
@@ -46,8 +46,12 @@ import {
import {SideNavCollapseButton} from './SideNavCollapseButton';
import {useSideNavRenderMode} from './SideNavRenderContext';
import {MobileNav} from '../MobileNav/MobileNav';
-import {useResizable} from '../Resizable/useResizable';
-import type {ResizableConfig} from '../Resizable/useResizable';
+import {
+ isResizableRegion,
+ useResizable,
+ type ResizableConfig,
+ type ResizableRegion,
+} from '../Resizable/useResizable';
import {ResizeHandle} from '../Resizable/ResizeHandle';
import {themeProps} from '../utils/themeProps';
import {SizeProvider} from '../SizeContext/SizeContext';
@@ -170,6 +174,54 @@ function describeCollapseConflict(
);
}
+/**
+ * The collapse config when `resizable` is useResizable's output. The region
+ * owns the collapse state and decides whether the rail collapses. A
+ * `collapsible` prop that sets state or disagrees is ignored and named in a
+ * development warning.
+ */
+function resolveRegionCollapseConfig(
+ collapsible: boolean | SideNavCollapsibleConfig | undefined,
+ region: ResizableRegion,
+): ResolvedCollapseConfig {
+ const fromCollapsible = typeof collapsible === 'object' ? collapsible : {};
+ const ignoredKeys = [
+ fromCollapsible.defaultIsCollapsed !== undefined && 'defaultIsCollapsed',
+ fromCollapsible.isCollapsed !== undefined && 'isCollapsed',
+ fromCollapsible.onCollapsedChange !== undefined && 'onCollapsedChange',
+ ].filter((key): key is string => key !== false);
+ // The region's own `collapsible` option decides whether the rail collapses.
+ const isRegionCollapsible = region.props._collapsible ?? false;
+ const conflicts: string[] = [];
+ if (ignoredKeys.length > 0) {
+ conflicts.push(
+ `\`collapsible\` sets ${ignoredKeys.map(key => `\`${key}\``).join(', ')}, ` +
+ 'which the rail ignores: it follows the region.',
+ );
+ }
+ if (
+ collapsible !== undefined &&
+ Boolean(collapsible) !== isRegionCollapsible
+ ) {
+ conflicts.push(
+ isRegionCollapsible
+ ? '`collapsible` is false, but the region is collapsible, so the rail still collapses.'
+ : '`collapsible` turns collapse on, but the region is not collapsible, so the rail never collapses.',
+ );
+ }
+ return {
+ isCollapsible: isRegionCollapsible,
+ defaultIsCollapsed: region.isCollapsed,
+ isCollapsed: undefined,
+ onCollapsedChange: undefined,
+ conflict:
+ conflicts.length === 0
+ ? null
+ : "`resizable` is useResizable's output, which owns the collapse " +
+ `state and decides whether the rail collapses. ${conflicts.join(' ')}`,
+ };
+}
+
// =============================================================================
// Styles
// =============================================================================
@@ -397,12 +449,18 @@ export interface SideNavProps extends BaseProps {
* - `defaultIsCollapsed` / `isCollapsed` / `onCollapseChange` — collapse
* state, when `resizable` should own it rather than `collapsible`
*
+ * - `useResizable()`'s output — the caller's region 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.
+ * The region's own `collapsible` option also decides whether the rail
+ * collapses
+ *
* When both props carry collapse state, `resizable` wins and a dev warning
* names the conflicting keys.
*
* @default false
*/
- resizable?: boolean | ResizableConfig;
+ resizable?: boolean | ResizableConfig | ResizableRegion;
/**
* Enables collapse behavior. The sidebar can be collapsed to a narrow
@@ -469,7 +527,7 @@ export function SideNav({
children,
footer,
footerIcons,
- collapsible = false,
+ collapsible: collapsibleProp,
resizable = false,
xstyle,
className,
@@ -480,15 +538,29 @@ export function SideNav({
...props
}: SideNavProps) {
const t = useComponentTranslator();
+ // Only a left-out `collapsible` becomes false, as before; `??` would also
+ // turn null into false.
+ const collapsible = collapsibleProp === undefined ? false : collapsibleProp;
// Parse collapsible prop
const collapsibleConfig = typeof collapsible === 'object' ? collapsible : {};
const hasCollapseButton = collapsibleConfig.hasButton ?? true;
+ // useResizable's output: the caller's region owns the width and collapse.
+ const ownerRegion = isResizableRegion(resizable) ? resizable : null;
// Resizable config
- const resizableConfig = typeof resizable === 'object' ? resizable : {};
+ const resizableConfig: ResizableConfig =
+ typeof resizable === 'object' && !isResizableRegion(resizable)
+ ? resizable
+ : {};
const isResizable = !!resizable;
- const collapseConfig = resolveCollapseConfig(collapsible, resizable);
+ const collapseConfig =
+ ownerRegion == null
+ ? resolveCollapseConfig(
+ collapsible,
+ isResizableRegion(resizable) ? false : resizable,
+ )
+ : resolveRegionCollapseConfig(collapsibleProp, ownerRegion);
const {isCollapsible, onCollapsedChange} = collapseConfig;
useDevWarning(
@@ -534,6 +606,8 @@ export function SideNav({
// useResizable is the sole collapse owner in every SideNav mode. Keeping that
// owner mounted when resize is toggled preserves the current collapse state.
+ // Given the hook's output, the caller's region is the owner, and this one
+ // stays idle.
const resizableHook = useResizable({
defaultSize: resizableConfig.defaultWidth ?? 260,
minSize: resizableConfig.minWidth ?? 180,
@@ -547,7 +621,9 @@ export function SideNav({
onCollapseChange: onCollapsedChange,
});
- const collapsed = resizableHook.isCollapsed;
+ // The region that sizes the rail: the caller's, or SideNav's own.
+ const region = ownerRegion ?? resizableHook;
+ const collapsed = region.isCollapsed;
// A collapse or expand plays the rail's motion, except an app's change
// before the rail's first paint. It is measured in the commit before the
@@ -752,11 +828,11 @@ export function SideNav({
};
if (next) {
- resizableHook.collapse();
+ region.collapse();
} else {
- resizableHook.expand();
+ region.expand();
}
- }, [collapsed, resizableHook]);
+ }, [collapsed, region]);
const showResizeHandle = isResizable && !collapsed;
@@ -875,7 +951,7 @@ export function SideNav({
// When resizable, override the nav width via inline style
const resizableNavStyle: React.CSSProperties | undefined = isResizable
- ? {...(style ?? {}), width: collapsed ? undefined : resizableHook.size}
+ ? {...(style ?? {}), width: collapsed ? undefined : region.size}
: style;
const navElement = (
@@ -957,7 +1033,7 @@ export function SideNav({
pillPlacement="end"
isAlwaysVisible={false}
xstyle={styles.resizableHandle}
- resizable={resizableHook.props}
+ resizable={ownerRegion ?? resizableHook.props}
label={t('@astryx.sideNav.resizeSidebar')}
aria-controls={navId}
/>