Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
777076f
test(Resizable): a LayoutPanel's pane slides closed and open
vjeux Nov 4, 2026
265fa15
feat(Resizable): a LayoutPanel's pane slides closed and open under ev…
vjeux Nov 4, 2026
a33ce9d
docs(LayoutPanel): a panel given the hook's own value slides closed a…
vjeux Nov 4, 2026
980c4e6
test(Resizable): a start and an end LayoutPanel slide with their hand…
vjeux Nov 4, 2026
2b4978a
docs(Layout): component:Resizable and component:LayoutPanel no longer…
vjeux Nov 4, 2026
dd2ccb0
test(Resizable): a press on the handle where it shows during a slide …
vjeux Nov 6, 2026
30b820a
fix(Resizable): the handle keeps its grab zone above the gliding content
vjeux Nov 6, 2026
553b8c1
test(Resizable): a pull past a limit rubber-bands and springs back, a…
vjeux Nov 6, 2026
a087d03
feat(Resizable): a pull past a limit rubber-bands and springs back, a…
vjeux Nov 6, 2026
6ae0738
docs(Resizable): FR8 records the rubber band at the limits and the cl…
vjeux Nov 6, 2026
b4d0155
test(Resizable): the separator's value keeps to its bounds while the …
vjeux Nov 6, 2026
a740700
fix(Resizable): the separator's value keeps to its bounds while the s…
vjeux Nov 6, 2026
44d42e5
test(Resizable): a spring-back past a limit lays out no frame
vjeux Nov 6, 2026
4157c28
fix(Resizable): the spring-back past a limit plays on the layout glide
vjeux Nov 6, 2026
34d2014
docs(Resizable): record the iOS mid-slide press gap
vjeux Nov 6, 2026
f0e46f5
docs(Resizable): drop the iOS mid-slide press gap
vjeux Nov 6, 2026
0dffc20
test(Resizable): a release slide plays the settle spring at the theme…
vjeux Nov 6, 2026
7bcf990
fix(Resizable): a release slide plays the settle spring at the theme'…
vjeux Nov 6, 2026
5ebf6f6
test(Resizable): a panel at the hook's size alone stops at a limit
vjeux Nov 6, 2026
d1e8334
fix(Resizable): only a pane a LayoutPanel renders rubber-bands
vjeux Nov 6, 2026
25eb0f4
refactor(Resizable): the release velocity reaches the region off Resi…
vjeux Nov 6, 2026
b009883
docs(Resizable): FR8 scopes the rubber band to a LayoutPanel's pane
vjeux Nov 6, 2026
63d8ce1
docs(motion): split long header and JSDoc sentences
vjeux Nov 6, 2026
cd5393d
test(Resizable): a pull past a limit moves the edge at most 8 px with…
vjeux Nov 6, 2026
b9bd820
fix(Resizable): a pull past a limit is drawn, at most 8 px, with no r…
vjeux Nov 6, 2026
7eccbd3
docs(Resizable): FR8 caps the rubber band at about 8 px; only the lif…
vjeux Nov 6, 2026
1d8205f
test(Resizable): slide captures wait for the commit by frames
vjeux Nov 6, 2026
fa3bfea
refactor(Resizable): split the rubber band and the lift close out
vjeux Nov 6, 2026
78117f7
Merge commit '12579501166dd59ec8a122c01e48a326fd125482' into HEAD
vjeux Nov 7, 2026
662ec2c
Merge commit 'a19d45a5dd9295fd3da378e4280cbe2815928133' into HEAD
vjeux Nov 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/layout-panel-slides-on-collapse.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@astryxdesign/core': patch
---

[feat] A `LayoutPanel` that takes a `useResizable` region in its `resizable` prop slides closed and open from the edge facing its `ResizeHandle` when the region collapses or opens, under every pointer, with the content beside it moving on the same motion: through `collapse()`, `expand()`, Enter, a double-click, the grow arrow, a controlled `isCollapsed`, or a drag past the collapse threshold. A drag that reopens the panel follows the pointer, and Reduce Motion is honored. A hand-built `ResizableProps` object does not slide. No prop or option is added.

@vjeux
204 changes: 204 additions & 0 deletions apps/storybook/stories/Resizable.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -840,3 +840,207 @@ export const CollapsingScrollPanelFixture: Story = {
tags: ['!autodocs', 'no-visual'],
render: () => <CollapsingScrollPanel />,
};

/**
* A LayoutPanel that takes a `useResizable` region slides closed and open
* from the edge facing its ResizeHandle. The content beside it moves on
* the same motion. "Close panel" collapses it from inside, so focus moves to
* the handle and the collapsed panel is inert. Enter on the handle, a
* double-click or "Open panel" opens it again.
*/
function SlidingPanelSplit({
side,
lineCount = 0,
}: {
side: 'start' | 'end';
lineCount?: number;
}) {
const resizable = useResizable({
defaultSize: 200,
minSize: 120,
maxSize: 320,
collapsible: true,
});
const pane = (
<LayoutPanel resizable={resizable} data-testid="sliding-panel">
<Stack gap={2}>
<Heading level={4}>Panel</Heading>
<Button label="Close panel" onClick={resizable.collapse} />
{Array.from({length: lineCount}, (_, index) => (
<Text key={index}>Line {index + 1}</Text>
))}
</Stack>
</LayoutPanel>
);
const handle = (
<ResizeHandle
resizable={resizable}
isReversed={side === 'end'}
hasDivider
label="Resize panel"
/>
);
return (
<div {...stylex.props(ps.shell)}>
<Layout
height="fill"
start={
side === 'start' ? (
<>
{pane}
{handle}
</>
) : undefined
}
content={
<LayoutContent data-testid="sliding-content">
<Stack gap={2}>
<Text>The content glides with the panel's edge.</Text>
<Button label="Open panel" onClick={resizable.expand} />
<Button
label="Toggle panel"
onClick={
resizable.isCollapsed ? resizable.expand : resizable.collapse
}
/>
</Stack>
</LayoutContent>
}
end={
side === 'end' ? (
<>
{handle}
{pane}
</>
) : undefined
}
/>
</div>
);
}

export const SlidingStartPanel: Story = {
render: () => <SlidingPanelSplit side="start" />,
};

export const SlidingEndPanel: Story = {
render: () => <SlidingPanelSplit side="end" />,
};

/** A sliding start panel with more lines than fit, for a scrolled slide. */
export const SlidingScrolledPanelFixture: Story = {
tags: ['!autodocs', 'no-visual'],
render: () => <SlidingPanelSplit side="start" lineCount={40} />,
};

/**
* A start LayoutPanel whose collapse state its parent owns: `isCollapsed`
* with `onCollapseChange`, and a toggle that flips the parent's state. The
* panel slides on each controlled change too.
*/
function ControlledSlidingPanel() {
const [isCollapsed, setIsCollapsed] = useState(false);
const resizable = useResizable({
defaultSize: 200,
minSize: 120,
maxSize: 320,
collapsible: true,
isCollapsed,
onCollapseChange: setIsCollapsed,
});
return (
<div {...stylex.props(ps.shell)}>
<Layout
height="fill"
start={
<>
<LayoutPanel resizable={resizable} data-testid="sliding-panel">
<Stack gap={2}>
<Heading level={4}>Panel</Heading>
<Button label="Close panel" onClick={resizable.collapse} />
</Stack>
</LayoutPanel>
<ResizeHandle
resizable={resizable}
hasDivider
label="Resize panel"
/>
</>
}
content={
<LayoutContent data-testid="sliding-content">
<Stack gap={2}>
<Text>The parent owns whether the panel is collapsed.</Text>
<Button
label="Toggle panel"
onClick={() => {
setIsCollapsed(!isCollapsed);
}}
/>
</Stack>
</LayoutContent>
}
/>
</div>
);
}

export const SlidingControlledPanel: Story = {
render: () => <ControlledSlidingPanel />,
};

/**
* Browser fixtures for two start panels. One takes the hook's own `props`
* and slides. The other takes a hand-built `ResizableProps` object, with the same
* fields in an object the hook did not return, and collapses without a slide.
* Both move focus inside them to the handle and go inert while collapsed.
*/
function PropsPanelSplit({isHandBuilt}: {isHandBuilt: boolean}) {
const resizable = useResizable({
defaultSize: 200,
minSize: 120,
maxSize: 320,
collapsible: true,
});
const panelValue = isHandBuilt ? {...resizable.props} : resizable.props;
return (
<div {...stylex.props(ps.shell)}>
<Layout
height="fill"
start={
<>
<LayoutPanel resizable={panelValue} data-testid="sliding-panel">
<Stack gap={2}>
<Heading level={4}>Panel</Heading>
<Button label="Close panel" onClick={resizable.collapse} />
</Stack>
</LayoutPanel>
<ResizeHandle
resizable={resizable}
hasDivider
label="Resize panel"
/>
</>
}
content={
<LayoutContent data-testid="sliding-content">
<Stack gap={2}>
<Text>The content glides with the panel's edge.</Text>
<Button label="Open panel" onClick={resizable.expand} />
</Stack>
</LayoutContent>
}
/>
</div>
);
}

export const SlidingPanelWithHookProps: Story = {
tags: ['!autodocs', 'no-visual'],
render: () => <PropsPanelSplit isHandBuilt={false} />,
};

export const PanelWithHandBuiltProps: Story = {
tags: ['!autodocs', 'no-visual'],
render: () => <PropsPanelSplit isHandBuilt />,
};
6 changes: 2 additions & 4 deletions docs/specs/components/Layout/LayoutPanel.spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,6 @@ 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 (FR7).** A resizable panel closes and opens at
once, where `spec:AST-105` FR9 slides it.

## Ownership boundary

Expand Down Expand Up @@ -125,7 +123,7 @@ Consumer migration instructions belong in consumer docs and release notes.
| FR4 | `hasDivider` draws a 1px solid `--color-border` line on the content-facing edge; LayoutPanel does not read a Layout divider default. Without a divider and with the automatic inset, the panel overlaps the content area by the inner inset so the two share one gap. One boundary has one divider owner (`family:layout-regions` FR5). Implementation gap: nothing prevents a panel divider and an adjacent ResizeHandle line from both painting (Current-state impact). | Current source and docs; `family:layout-regions` FR5, FR6 |
| 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 |
| 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. A panel at the region's size beside that handle does not slide. | `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
Expand Down Expand Up @@ -211,7 +209,7 @@ of its own, so no anatomy-theming block is declared here.
| FR4 | Source review | `hasDivider`; no divider | Gap: no test covers the divider line, the shared gap, or a panel divider beside a ResizeHandle. | `audit:LayoutPanel/layout` |
| FR5, AR1 | `LayoutSlots.test.tsx` landmark test; `Layout.a11y.test.tsx` and `Layout.a11y.chromium.spec.ts` `start-end-navigation-rtl` and `nested-panel-region` states | Caller `navigation` and `region` roles and labels | A lost role or name fails, including at the accessibility tree. Gap: no test asserts that a panel without `role` exposes none. | `audit:LayoutPanel/accessibility` |
| FR6 | `LayoutSlots.test.tsx` ref and className tests | Ref; caller class | A ref on another element or a dropped class fails. Gap: no test covers `style`, DOM, data, or event props, or the `xstyle` merge order. | `audit:LayoutPanel/api` |
| FR7, AR2 | Real-browser frame capture of a start and an end panel collapsing and opening, with focus inside, with the hook's `props` and with a hand-built object (pending) | Start and end; toggle, Enter and a drag past the threshold; configuration-object form; Reduce Motion | A jump instead of a slide, a gap beside the moving edge, a slide under the configuration-object form, focus left in a collapsed panel, a collapsed panel reachable by Tab or exposed to assistive technology, or a new prop fails. | `audit:LayoutPanel/behavior` |
| FR7, AR2 | `paneSlide.interaction.browser.spec.ts`: a start and an end panel collapsing and opening, with focus inside, with the hook's own value and with a hand-built one | Start and end; toggle, Enter and a drag past the threshold; configuration-object form; Reduce Motion | A jump instead of a slide, a gap beside the moving edge, a slide under the configuration-object form, focus left in a collapsed panel, a collapsed panel reachable by Tab or exposed to assistive technology, or a new prop fails. | `audit:LayoutPanel/behavior` |
| AR3 | `useScrollRegionProps.test.tsx` LayoutPanel tests; `scrollRegionName.a11y.chromium.spec.ts` `panel-scrolls`; `collapsedPanel.interaction.browser.spec.ts` | Scrolling panel; caller `navigation` role and label; a scrolling panel collapsed to width 0 | 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. A group role kept at width 0 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` |
Expand Down
Loading
Loading