From 777076fd3a7bd880e629c3d3e037d64d4f062837 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Wed, 4 Nov 2026 09:08:06 -0800 Subject: [PATCH 01/67] test(Resizable): a LayoutPanel's pane slides closed and open Cases for a LayoutPanel that takes a useResizable region's own props: it is measured before the collapse commits and plays from the edge facing its ResizeHandle, on every collapse and open, a controlled isCollapsed and a drag past the threshold, with nothing for a drag that reopens it, the first render or a plain resize. A hand-built object, a pane at the hook's size alone, and a LayoutPanel at the hook's size beside a handle given the props show no slide. Two cases for the shared edge reveal: a conceal keeps the padding and overflow the element was laid out with and puts back the element's own values once the motion ends, and it keeps the scroll offset, which a reveal that reverses it scrolls back to. These cases fail until the next commit. --- .../core/src/Resizable/paneReveal.test.tsx | 518 ++++++++++++++++++ .../src/motion/animatedLayout.edge.test.ts | 53 +- 2 files changed, 568 insertions(+), 3 deletions(-) create mode 100644 packages/core/src/Resizable/paneReveal.test.tsx diff --git a/packages/core/src/Resizable/paneReveal.test.tsx b/packages/core/src/Resizable/paneReveal.test.tsx new file mode 100644 index 0000000000000..9ea40aba4ce02 --- /dev/null +++ b/packages/core/src/Resizable/paneReveal.test.tsx @@ -0,0 +1,518 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file paneReveal.test.tsx + * @input vitest (jsdom), Testing Library, Layout with a LayoutPanel beside a + * ResizeHandle, the useResizable hook's region, and a recording + * stand-in for the animated-layout module's edge reveal + * @output Checks when a LayoutPanel's pane slides on collapse and open, from + * which edge and around which commit + * @position Integration tests for createResizable.ts, resizablePane.ts, + * LayoutPanel.tsx and ResizeHandle.tsx + */ + +import {act, fireEvent, render, screen} from '@testing-library/react'; +import {useLayoutEffect, useState} from 'react'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {__resetInteractionModalityForTest} from '../utils/interactionModality'; +import type * as AnimatedLayoutModule from '../motion/animatedLayout'; +import {Layout} from '../Layout/Layout'; +import {LayoutContent} from '../Layout/LayoutContent'; +import {LayoutPanel} from '../Layout/LayoutPanel'; +import {ResizeHandle} from './ResizeHandle'; +import { + useResizable, + type ResizableRegion, + type UseResizableSingleConfig, +} from './useResizable'; + +interface RecordedReveal { + edge: string; + target: () => Element | null; + /** The pane's inline style when each call ran. */ + measuredStyle: string | null; + animatedStyle: string | null; + measureBefore: ReturnType; + animate: ReturnType; + cancel: ReturnType; +} + +const reveals: RecordedReveal[] = []; + +vi.mock('../motion/animatedLayout', async importOriginal => { + const original = await importOriginal(); + return { + ...original, + reportLayoutEdge: vi.fn( + ( + _handle: unknown, + target: (() => Element | null) | {current: Element | null}, + {edge}: {edge: string}, + ) => { + const resolve = () => + typeof target === 'function' ? target() : target.current; + const recorded: RecordedReveal = { + edge, + target: resolve, + measuredStyle: null, + animatedStyle: null, + measureBefore: vi.fn(() => { + recorded.measuredStyle = resolve()?.getAttribute('style') ?? null; + }), + animate: vi.fn(() => { + recorded.animatedStyle = resolve()?.getAttribute('style') ?? null; + }), + cancel: vi.fn(), + }; + reveals.push(recorded); + return { + measureBefore: recorded.measureBefore, + animate: recorded.animate, + cancel: recorded.cancel, + }; + }, + ), + }; +}); + +let propsRegion: ResizableRegion | undefined; + +const NO_CONFIG: UseResizableSingleConfig = {}; + +/** + * A Layout whose start panel, or end panel when reversed, takes the + * hook's own `props`. Or it takes a hand-built copy: the + * same fields in an object `useResizable` did not return. Or, with + * `isWidthPanel`, it renders at the hook's size beside a handle given the + * props. + */ +function PropsSplit({ + config = NO_CONFIG, + isReversed = false, + isHandBuilt = false, + isWidthPanel = false, + panelKey = 'panel', +}: { + config?: UseResizableSingleConfig; + isReversed?: boolean; + isHandBuilt?: boolean; + isWidthPanel?: boolean; + panelKey?: string; +}) { + const value = useResizable({defaultSize: 200, collapsible: true, ...config}); + propsRegion = value; + const panelProps = isHandBuilt ? {...value.props} : value.props; + const panel = isWidthPanel ? ( + + + + ) : ( + + + + ); + const handle = ( + + ); + return ( + <> + + + {panel} + {handle} + + ) + } + content={Main} + end={ + isReversed ? ( + <> + {handle} + {panel} + + ) : undefined + } + /> + + ); +} + +interface ParentCommit { + parent: boolean; + hook: boolean; + /** Whether the pane rendered its open width (200px) in this commit. */ + isOpenWidth: boolean; +} +let commits: ParentCommit[] = []; + +/** A parent that owns the collapse state through onCollapseChange. */ +function ControlledSplit() { + const [isCollapsed, setIsCollapsed] = useState(false); + const options = { + defaultSize: 200, + collapsible: true, + isCollapsed, + onCollapseChange: setIsCollapsed, + }; + return ; +} + +function useRecordParent(parent: boolean, hook: boolean) { + useLayoutEffect(() => { + commits.push({ + parent, + hook, + isOpenWidth: ( + screen.getByTestId('pane').getAttribute('style') ?? '' + ).includes('200px'), + }); + }); +} + +function ControlledConfig({ + isCollapsed, + options, +}: { + isCollapsed: boolean; + options: UseResizableSingleConfig; +}) { + const value = useResizable(options); + propsRegion = value; + useRecordParent(isCollapsed, value.isCollapsed); + return ( + + + + + } + content={Main} + /> + ); +} + +const pane = () => screen.getByTestId('pane'); +const separator = () => screen.getByRole('separator', {name: 'Resize pane'}); +/** The grab zone, which takes the pointer. */ +const grabZone = () => separator().firstElementChild as HTMLElement; + +beforeEach(() => { + __resetInteractionModalityForTest(); + commits = []; + reveals.length = 0; + propsRegion = undefined; + localStorage.clear(); +}); + +afterEach(() => { + document.body.style.cursor = ''; + document.body.style.userSelect = ''; +}); + +describe("a LayoutPanel's pane slides on collapse and open (component:Resizable FR8, component:LayoutPanel FR7)", () => { + it('measures a start pane before its collapse commits and plays from its inline-end edge after the pane renders its new width', () => { + render(); + expect(reveals).toHaveLength(0); + act(() => { + propsRegion?.collapse(); + }); + expect(reveals).toHaveLength(1); + const [reveal] = reveals; + expect(reveal.edge).toBe('inline-end'); + expect(reveal.target()).toBe(pane()); + expect(reveal.measureBefore).toHaveBeenCalledTimes(1); + expect(reveal.animate).toHaveBeenCalledTimes(1); + // Measured at the old width, played at the new one. + expect(reveal.measuredStyle).toContain('200px'); + expect(reveal.animatedStyle).not.toContain('200px'); + expect(reveal.measureBefore.mock.invocationCallOrder[0]).toBeLessThan( + reveal.animate.mock.invocationCallOrder[0], + ); + }); + + it('plays an end pane, beside a reversed handle, from its inline-start edge', () => { + render(); + act(() => { + propsRegion?.collapse(); + }); + expect(reveals.map(r => r.edge)).toEqual(['inline-start']); + expect(reveals[0].target()).toBe(pane()); + }); + + it('plays every collapse and open: collapse(), expand(), a resize out of collapse, Enter, a double-click and the grow arrow', () => { + render(); + act(() => { + propsRegion?.collapse(); + }); + act(() => { + propsRegion?.expand(); + }); + act(() => { + propsRegion?.collapse(); + }); + act(() => { + propsRegion?.resize(260); + }); + fireEvent.keyDown(separator(), {key: 'Enter'}); + fireEvent.keyDown(separator(), {key: 'Enter'}); + fireEvent.doubleClick(separator()); + fireEvent.keyDown(separator(), {key: 'ArrowRight'}); + expect(reveals).toHaveLength(8); + for (const reveal of reveals) { + expect(reveal.edge).toBe('inline-end'); + expect(reveal.measureBefore).toHaveBeenCalledTimes(1); + expect(reveal.animate).toHaveBeenCalledTimes(1); + } + }); + + it('plays a controlled isCollapsed change', () => { + const view = render(); + view.rerender(); + view.rerender(); + expect(reveals).toHaveLength(2); + expect(reveals.every(r => r.animate.mock.calls.length === 1)).toBe(true); + }); + + it('plays a drag that crosses the collapse threshold, plays nothing for a drag that reopens, and lands a running conceal then', () => { + render(); + fireEvent.pointerDown(grabZone(), {pointerId: 1, clientX: 300}); + fireEvent.pointerMove(grabZone(), {pointerId: 1, clientX: 110}); + expect(propsRegion?.isCollapsed).toBe(true); + expect(reveals).toHaveLength(1); + expect(reveals[0].animate).toHaveBeenCalledTimes(1); + fireEvent.pointerMove(grabZone(), {pointerId: 1, clientX: 320}); + expect(propsRegion?.isCollapsed).toBe(false); + expect(reveals).toHaveLength(1); + expect(reveals[0].cancel).toHaveBeenCalledTimes(1); + fireEvent.pointerUp(grabZone(), {pointerId: 1, clientX: 320}); + }); + + it('plays nothing for the first render or a resize that neither collapses nor opens', () => { + render(); + act(() => { + propsRegion?.expand(); + }); + expect(reveals).toHaveLength(1); + fireEvent.keyDown(separator(), {key: 'ArrowLeft'}); + act(() => { + propsRegion?.resize(240); + }); + expect(reveals).toHaveLength(1); + }); + + it('lands a running motion at once when the region goes away', () => { + const view = render(); + act(() => { + propsRegion?.collapse(); + }); + view.unmount(); + expect(reveals[0].cancel).toHaveBeenCalledTimes(1); + }); + + it('shows no reveal for a pane the caller renders at the hook size alone', () => { + function HookOnly() { + const value = useResizable({defaultSize: 200, collapsible: true}); + propsRegion = value; + return ( +
+
+ +
+ +
+ ); + } + render(); + act(() => { + propsRegion?.collapse(); + }); + expect(reveals).toHaveLength(0); + expect(pane()).not.toHaveAttribute('inert'); + }); + + it("slides a pane that takes the hook's own props, from the edge facing its handle, measured before the collapse commits", () => { + for (const isReversed of [false, true]) { + reveals.length = 0; + const view = render(); + fireEvent.keyDown(separator(), {key: 'Enter'}); + expect(separator()).toHaveAttribute('aria-valuetext', 'Collapsed'); + expect(reveals).toHaveLength(1); + const [reveal] = reveals; + expect(reveal.edge).toBe(isReversed ? 'inline-start' : 'inline-end'); + expect(reveal.target()).toBe(pane()); + expect(reveal.measuredStyle).toContain('200px'); + expect(reveal.animatedStyle).not.toContain('200px'); + expect(reveal.animate).toHaveBeenCalledTimes(1); + fireEvent.keyDown(separator(), {key: 'Enter'}); + expect(reveals).toHaveLength(2); + expect(reveals[1].measuredStyle).not.toContain('200px'); + expect(reveals[1].animatedStyle).toContain('200px'); + view.unmount(); + } + }); + + it("slides the pane of a region whose collapse() ran before the hook's first layout effect", () => { + /** A child's layout effect runs before its parent's. */ + function EarlyCollapse({target}: {target: ResizableRegion}) { + const [initial] = useState(target); + useLayoutEffect(() => { + initial.collapse(); + }, [initial]); + return null; + } + function EarlySplit() { + const value = useResizable({defaultSize: 200, collapsible: true}); + propsRegion = value; + return ( + + + + + + + + } + content={Main} + /> + ); + } + render(); + expect(separator()).toHaveAttribute('aria-valuetext', 'Collapsed'); + reveals.length = 0; + act(() => { + propsRegion?.expand(); + }); + expect(reveals).toHaveLength(1); + expect(reveals[0].target()).toBe(pane()); + }); + + it("plays every collapse and open for the hook's props, and nothing for a drag that reopens", () => { + render(); + act(() => { + propsRegion?.collapse(); + }); + act(() => { + propsRegion?.expand(); + }); + act(() => { + propsRegion?.collapse(); + }); + act(() => { + propsRegion?.resize(260); + }); + fireEvent.doubleClick(separator()); + fireEvent.keyDown(separator(), {key: 'ArrowRight'}); + expect(reveals).toHaveLength(6); + for (const reveal of reveals) { + expect(reveal.edge).toBe('inline-end'); + expect(reveal.measureBefore).toHaveBeenCalledTimes(1); + expect(reveal.animate).toHaveBeenCalledTimes(1); + } + fireEvent.pointerDown(grabZone(), {pointerId: 1, clientX: 300}); + fireEvent.pointerMove(grabZone(), {pointerId: 1, clientX: 100}); + expect(propsRegion?.isCollapsed).toBe(true); + expect(reveals).toHaveLength(7); + expect(reveals[6].animate).toHaveBeenCalledTimes(1); + fireEvent.pointerMove(grabZone(), {pointerId: 1, clientX: 340}); + expect(propsRegion?.isCollapsed).toBe(false); + expect(reveals).toHaveLength(7); + expect(reveals[6].cancel).toHaveBeenCalled(); + fireEvent.pointerUp(grabZone(), {pointerId: 1, clientX: 340}); + }); + + it("plays a controlled isCollapsed change for the hook's props", () => { + const view = render(); + view.rerender(); + expect(pane().getAttribute('style') ?? '').not.toContain('200px'); + view.rerender(); + expect(reveals).toHaveLength(2); + expect(reveals.every(r => r.animate.mock.calls.length === 1)).toBe(true); + }); + + it('lands the motion of a pane that takes the hook props when the region goes away', () => { + const view = render(); + act(() => { + propsRegion?.collapse(); + }); + view.unmount(); + expect(reveals[0].cancel).toHaveBeenCalledTimes(1); + }); + + it('shows no reveal for a hand-built ResizableProps object', () => { + render(); + act(() => { + propsRegion?.collapse(); + }); + expect(pane().getAttribute('style') ?? '').not.toContain('200px'); + act(() => { + propsRegion?.expand(); + }); + expect(pane().getAttribute('style') ?? '').toContain('200px'); + expect(reveals).toHaveLength(0); + }); + + it("measures a controlled change before the DOM changes and reports it in the commit of the parent's state", () => { + render(); + act(() => { + propsRegion?.collapse(); + }); + expect(reveals).toHaveLength(1); + expect(reveals[0].measuredStyle).toContain('200px'); + expect(reveals[0].animatedStyle).not.toContain('200px'); + expect(reveals[0].animate).toHaveBeenCalledTimes(1); + fireEvent.keyDown(separator(), {key: 'Enter'}); + expect(reveals).toHaveLength(2); + expect(reveals[1].measuredStyle).not.toContain('200px'); + expect(reveals[1].animatedStyle).toContain('200px'); + for (const commit of commits) { + expect(commit.hook, JSON.stringify(commits)).toBe(commit.parent); + expect(commit.isOpenWidth, JSON.stringify(commits)).toBe(!commit.parent); + } + }); + + it('hands a LayoutPanel that replaces another to its region', () => { + const refView = render(); + refView.rerender(); + act(() => { + propsRegion?.collapse(); + }); + expect(reveals).toHaveLength(1); + expect(reveals[0].target()).toBe(pane()); + refView.unmount(); + + reveals.length = 0; + const propsView = render(); + propsView.rerender(); + act(() => { + propsRegion?.collapse(); + }); + expect(reveals).toHaveLength(1); + expect(reveals[0].target()).toBe(pane()); + }); + + it("shows no reveal for a LayoutPanel at the hook's size beside a handle given its props, which still holds it collapsed", () => { + for (const isReversed of [false, true]) { + reveals.length = 0; + const view = render(); + act(() => { + propsRegion?.collapse(); + }); + expect(pane()).toHaveAttribute('inert'); + act(() => { + propsRegion?.expand(); + }); + expect(reveals).toHaveLength(0); + view.unmount(); + } + }); +}); diff --git a/packages/core/src/motion/animatedLayout.edge.test.ts b/packages/core/src/motion/animatedLayout.edge.test.ts index 4228f791da38f..b1fa8a33f5725 100644 --- a/packages/core/src/motion/animatedLayout.edge.test.ts +++ b/packages/core/src/motion/animatedLayout.edge.test.ts @@ -15,7 +15,8 @@ * extent it shows, and Reduce Motion by pointer; and a resized element's * reveal with no copy (a resize controller's pane): held at the extent it * was laid out at while it conceals, its edge's margin freeing the - * difference, clipped to its new extent, riding the content beside it + * difference, its padding, overflow and scroll offset kept as laid out, + * clipped to its new extent, riding the content beside it * @position Testing; the real geometry is proven in * __tests__/animatedLayout.interaction.browser.spec.ts and, for the SideNav * rail's two-state form, SideNav's rail-motion specs @@ -643,11 +644,15 @@ function paneSetup({ handle, pane: paneEl, grip, - /** Collapses or opens the pane, as the controller does, around the report. */ - resize(next: number, edge: LayoutEdge) { + /** + * Collapses or opens the pane, as the controller does, around the report. + * `commit` changes what else the new size changes in the same commit. + */ + resize(next: number, edge: LayoutEdge, commit?: () => void) { const report = reportLayoutEdge(handle, () => paneEl, {edge}); report.measureBefore(); width = next; + commit?.(); report.animate(); return report; }, @@ -678,6 +683,48 @@ describe('the edge reveal of a resized element, with no copy (spec:AST-105 FR9)' expect(pane.style.getPropertyValue('margin-inline-end')).toBe(''); }); + it('keeps the padding and overflow it was laid out with while it conceals, and puts back its own once the motion ends', () => { + const {pane, resize} = paneSetup(); + pane.style.setProperty('padding-inline-start', '16px'); + pane.style.setProperty('padding-inline-end', '16px'); + pane.style.setProperty('overflow-y', 'auto'); + resize(0, 'inline-end', () => { + // A LayoutPanel at width 0 drops its padding and stops scrolling. + pane.style.setProperty('padding-inline-start', '0px'); + pane.style.setProperty('padding-inline-end', '0px'); + pane.style.setProperty('overflow-y', 'clip'); + }); + expect(pane.style.getPropertyValue('padding-inline-start')).toBe('16px'); + expect(pane.style.getPropertyValue('padding-inline-end')).toBe('16px'); + expect(pane.style.getPropertyValue('overflow-y')).toBe('auto'); + clipsOf(pane)[0].finish?.(); + expect(pane.style.getPropertyValue('padding-inline-start')).toBe('0px'); + expect(pane.style.getPropertyValue('padding-inline-end')).toBe('0px'); + expect(pane.style.getPropertyValue('overflow-y')).toBe('clip'); + }); + + it('keeps the scroll offset it was laid out at while it conceals, and a reveal that reverses it scrolls back there', () => { + const {pane, resize} = paneSetup(); + let scrollTop = 120; + Object.defineProperty(pane, 'scrollTop', { + configurable: true, + get: () => scrollTop, + set: (value: number) => { + scrollTop = value; + }, + }); + // A pane that stops scrolling in the commit loses its offset. + resize(48, 'inline-end', () => { + scrollTop = 0; + }); + expect(scrollTop).toBe(120); + // Releasing the hold to reverse loses it again. + resize(260, 'inline-end', () => { + scrollTop = 0; + }); + expect(scrollTop).toBe(120); + }); + it('conceals entirely to a size of 0', () => { const {pane, resize} = paneSetup(); resize(0, 'inline-end'); From 265fa15fc9f5bdf047803ee3f00a97e88ba3fe17 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Wed, 4 Nov 2026 09:08:06 -0800 Subject: [PATCH 02/67] feat(Resizable): a LayoutPanel's pane slides closed and open under every pointer A LayoutPanel given a useResizable region's own props hands its element to the region, and the region's internal factory plays `spec:AST-105` FR9's edge reveal as it collapses or opens, under every pointer (FR5). It measures in the commit that renders the change, before the DOM changes, and plays once the pane renders its new size. A pointer drag that reopens the pane follows the pointer instead, which the ResizeHandle given the props reports to the region. The slide picks no timing of its own: it follows the shared animated-layout module. No prop, option, export or type changes. The panel lends its element from a layout Effect instead of its callback ref, both to the region and to a ResizeHandle that finds it beside it. At width 0 the panel tells the scroll-region hook it does not scroll, which changes that hook's ref. React then runs every merged ref again, so a lend in the callback ref was taken back and given again mid-collapse, which landed the slide before it played. While the shared edge reveal holds a concealing element at the extent it was laid out at, it now also keeps the element's padding, overflow and scroll offset as laid out, and puts back the element's own values once the motion ends. A LayoutPanel at width 0 drops its padding and stops scrolling in the collapsing commit, so without that its children jumped by the padding and a scrolled panel jumped to its top as the slide began. A reveal that reverses a conceal scrolls back to where the conceal kept it. --- packages/core/src/Layout/LayoutPanel.tsx | 62 +++-- packages/core/src/Resizable/ResizeHandle.tsx | 13 +- .../core/src/Resizable/createResizable.ts | 129 ++++++++- packages/core/src/Resizable/resizablePane.ts | 248 ++++++++++++++++-- packages/core/src/Resizable/useResizable.ts | 34 ++- .../motion/animatedLayout.boundary.test.ts | 4 +- packages/core/src/motion/animatedLayout.ts | 89 ++++++- 7 files changed, 522 insertions(+), 57 deletions(-) diff --git a/packages/core/src/Layout/LayoutPanel.tsx b/packages/core/src/Layout/LayoutPanel.tsx index a2d02c57275ea..3ff85d91ee26a 100644 --- a/packages/core/src/Layout/LayoutPanel.tsx +++ b/packages/core/src/Layout/LayoutPanel.tsx @@ -7,8 +7,8 @@ * @input Uses React StyleX, LayoutAreaContext, LayoutSlotsContext, * 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. + * @position Sidebar panel for Layout start/end slots; its resize controller slides it. + * Use for navigation panels, settings sidebars, detail panels, or any fixed-width side content. * * SYNC: When modified, update these files to stay in sync: * - /packages/core/src/Layout/Layout.doc.mjs @@ -34,6 +34,7 @@ import { } from '../Resizable/useResizable'; import { createResizablePaneLink, + regionOfProps, resizeHandleBeside, } from '../Resizable/resizablePane'; import {LayoutChangeSnapshot} from '../motion/LayoutChangeSnapshot'; @@ -252,30 +253,57 @@ export function LayoutPanel({ // When resizable props are provided, use the hook-driven size const effectiveWidth = resizable ? resizable._size : width; + // A collapsed region sizes its panel to 0. + const isZeroWidth = effectiveWidth === 0; + const elementRef = useRef(null); - // 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. + // A panel sized by a resize controller lends it its element. The + // controller slides it on collapse and open. Given the hook's own `props`, + // the panel hands itself to that region. With no `resizable`, a + // ResizeHandle beside it that has the region's props finds it and holds it + // collapsed. A hand-built `resizable` object has no region: it lends + // nothing and never slides. 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], - ); + const isFoundBeside = resizable == null; + const paneRegion = resizable == null ? undefined : regionOfProps(resizable); + // Syncs the panel's element with its resize controller. It lends here, not + // in the callback ref. The scroll region's ref changes at width 0, so React + // runs every merged ref again. A lend taken back mid-collapse would land the + // slide. + useIsomorphicLayoutEffect(() => { + const isLent = isFoundBeside || paneRegion != null; + const lend = (element: HTMLDivElement | null) => { + const pane = paneLink.attach(isLent ? element : null, isFoundBeside); + paneRegion?.attach(pane); + }; + lend(elementRef.current); + return () => { + lend(null); + }; + }, [paneLink, isFoundBeside, paneRegion]); + const contentFacingEdge = area === 'end' ? 'inline-start' : 'inline-end'; + // A collapse or open reaches the controller before the DOM changes, in the + // commit that renders it. That holds for the controller's own change and + // for a controlled `isCollapsed` it hears of after this commit. + const tellBeforeChange = useCallback(() => { + paneLink.beforeChange(isZeroWidth); + }, [paneLink, isZeroWidth]); + // Syncs the resize controller's pane link with the width and edge this + // commit rendered. + useIsomorphicLayoutEffect(() => { + paneLink.committed(effectiveWidth, contentFacingEdge); + }); // 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( @@ -313,8 +341,6 @@ export function LayoutPanel({ const isEndPanel = area === 'end'; const isZeroPadding = padding === 0; - // A collapsed region sizes its panel to 0. - const isZeroWidth = effectiveWidth === 0; // When no divider, collapse spacing for seamless visual flow const shouldCollapseSpacing = @@ -325,7 +351,7 @@ export function LayoutPanel({ role, label, }); - const mergedRef = useMergedRefs(ref, region.ref, elementRef, lendRef); + const mergedRef = useMergedRefs(ref, region.ref, elementRef); // Select divider style based on position const dividerStyle = isStartPanel @@ -375,6 +401,10 @@ export function LayoutPanel({ )} {...props} {...(isConcealed ? {inert: true, 'aria-hidden': true} : {})}> + { + setIsDragging(dragging); + // The hook's region lets a pointer drag reopen its pane without a + // slide; a hand-built object has none. + const region = resizableRef.current; + if (region != null) { + regionOfProps(region)?.setPointerDragging(dragging); + } + }, ); }); @@ -564,6 +573,7 @@ export function ResizeHandle({ heldPaneRef.current = handOverPane( heldPaneRef.current, null, + null, false, null, ); @@ -586,6 +596,7 @@ export function ResizeHandle({ heldPaneRef.current = handOverPane( heldPaneRef.current, pane, + null, isRegionCollapsed, element, ); diff --git a/packages/core/src/Resizable/createResizable.ts b/packages/core/src/Resizable/createResizable.ts index f6fa41164410b..3b95cb85beb86 100644 --- a/packages/core/src/Resizable/createResizable.ts +++ b/packages/core/src/Resizable/createResizable.ts @@ -2,8 +2,8 @@ /** * @file createResizable.ts - * @input A handle element, or null for a region with no handle yet, and the - * region's options + * @input A handle element, or null for a region with no handle yet, the + * region's options, and the pane a LayoutPanel lends (resizablePane.ts) * @output createResizable; readInitialResizableState for the hook's first * render; createResizableAs and the internal channel for the hooks * @position The Resizable family's factory, internal to the package. Its @@ -39,6 +39,13 @@ import { type ResizeGestureModel, type ResizeGestureState, } from './resizeGesture'; +import { + createPaneReveal, + handOverPane, + type PaneListener, + type RegionPaneSlot, + type ResizablePane, +} from './resizablePane'; import {separatorAttributes} from './separatorAttributes'; import type {ResizableSize} from './utils'; @@ -300,6 +307,12 @@ interface ResizableInternals { * keys of a ResizeHandle given the configuration object's props. */ model: ResizeGestureModel; + /** + * The hook's region: the pane its LayoutPanel hands over, + * and whether its ResizeHandle's pointer drags. Returns the function that + * stops following it. + */ + followRegion(slot: RegionPaneSlot): () => void; } const internals = new WeakMap(); @@ -444,6 +457,21 @@ export function createResizableAs( let snapshot: ResizableSnapshot | null = null; const snapshotListeners = new Set<() => void>(); + // The pane is the LayoutPanel the hook's region hands over. The factory + // measures its edge reveal before the DOM changes. It plays the reveal once + // the pane renders its new size. + let pane: ResizablePane | null = null; + let regionSlot: RegionPaneSlot | null = null; + const paneReveal = createPaneReveal( + () => pane?.element.parentElement ?? null, + ); + // `pendingFlip` is a flip this factory made that the pane has not rendered + // yet. `expectedFlip` is a controlled flip that the pane rendered and + // measured first. + let pendingFlip: {toCollapsed: boolean; isPlayable: boolean} | null = null; + let expectedFlip: boolean | null = null; + let isPointerReopening = false; + const direction = (): ResizableDirection => opts.direction ?? 'horizontal'; const collapsible = (): boolean => opts.collapsible ?? false; const isControlled = (): boolean => opts.isCollapsed !== undefined; @@ -649,6 +677,28 @@ export function createResizableAs( size: derived.isCollapsed ? 0 : derived.size, isCollapsed: derived.isCollapsed, }; + if (notified != null) { + if (notified.isCollapsed !== reported.isCollapsed) { + if (expectedFlip === reported.isCollapsed) { + // The pane rendered this change first and has measured it. + expectedFlip = null; + pendingFlip = null; + } else { + pendingFlip = { + toCollapsed: reported.isCollapsed, + isPlayable: !isPointerReopening, + }; + if (isPointerReopening) { + // The pointer drives the edge: a running conceal lands at once. + paneReveal.land(); + } + } + } else if (notified.size !== reported.size) { + // A resize that neither collapses nor opens lands a running motion. + paneReveal.land(); + } + } + pane?.setCollapsed(reported.isCollapsed, handle); const next: ResizableSnapshot = { size: reported.size, isCollapsed: reported.isCollapsed, @@ -685,6 +735,51 @@ export function createResizableAs( } } + // --- The pane a LayoutPanel lends --- + + const paneListener: PaneListener = { + beforeChange(toCollapsed) { + const current = pane; + if (current == null) { + return; + } + if (pendingFlip?.toCollapsed === toCollapsed) { + const {isPlayable} = pendingFlip; + pendingFlip = null; + if (isPlayable) { + paneReveal.measure(current, current.contentFacingEdge()); + } else { + paneReveal.land(); + } + return; + } + // The pane renders a collapse or open this factory has not made yet, + // ahead of the size it reports. A controlled isCollapsed does that: it + // reaches the factory after this commit. It plays too. + const reportedSize = derived.isCollapsed ? 0 : derived.size; + const isAhead = toCollapsed ? reportedSize !== 0 : reportedSize === 0; + if (collapsible() && isAhead && derived.isCollapsed !== toCollapsed) { + expectedFlip = toCollapsed; + paneReveal.measure(current, current.contentFacingEdge()); + } + }, + committed() { + if (pane != null) { + paneReveal.committed(pane); + } + }, + }; + + function setPane(next: ResizablePane | null): void { + if (next === pane) { + return; + } + paneReveal.land(); + pendingFlip = null; + expectedFlip = null; + pane = handOverPane(pane, next, paneListener, derived.isCollapsed, handle); + } + /** Nothing is written while the basis stands in, nor while a gesture runs. */ function persist(): void { const key = opts.autoSaveId; @@ -853,7 +948,10 @@ export function createResizableAs( } const clamped = clampSize(raw, derived.min, derived.max, snaps()); chosenSize = clamped; + // Only a pointer reopens without a reveal; a key's step plays one. + isPointerReopening = reopens && regionSlot?.isPointerDragging() === true; commit(); + isPointerReopening = false; if (reopens) { opts.onCollapseChange?.(false); } @@ -957,6 +1055,8 @@ export function createResizableAs( } bindHandle(); + let stopFollowingRegion: (() => void) | null = null; + commit(); const controller: ResizableController = { @@ -997,6 +1097,10 @@ export function createResizableAs( // than the factory's own, ends the same way. endGesture(); isDestroyed = true; + stopFollowingRegion?.(); + stopFollowingRegion = null; + paneReveal.land(); + pane = handOverPane(pane, null, paneListener, false, null); if (!isSuspended) { letGoOfPage(); } @@ -1020,6 +1124,8 @@ export function createResizableAs( gesture.interrupt(); endGesture(); isSuspended = true; + // A suspended region plays no reveal, so a running one lands now. + paneReveal.land(); letGoOfPage(); }, resume() { @@ -1041,6 +1147,25 @@ export function createResizableAs( commit(); } }, + followRegion(slot) { + stopFollowingRegion?.(); + regionSlot = slot; + const stop = slot.follow(next => { + if (!isDestroyed) { + setPane(next); + } + }); + stopFollowingRegion = () => { + stop(); + if (regionSlot === slot) { + regionSlot = null; + } + }; + return () => { + stopFollowingRegion?.(); + stopFollowingRegion = null; + }; + }, }); return controller; } diff --git a/packages/core/src/Resizable/resizablePane.ts b/packages/core/src/Resizable/resizablePane.ts index b2591e36aebed..9b2c1dc84ab14 100644 --- a/packages/core/src/Resizable/resizablePane.ts +++ b/packages/core/src/Resizable/resizablePane.ts @@ -2,24 +2,53 @@ /** * @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 + * @input A LayoutPanel's element, its commits and the width it renders; the + * collapse state its resize controller reports; motion/animatedLayout.ts + * @output The internal channel through which a LayoutPanel lends its element + * to the resize controller that sizes it, the edge reveal that controller + * plays, 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. */ +import { + createAnimatedLayout, + reportLayoutEdge, + type LayoutEdge, +} from '../motion/animatedLayout'; +import type {ResizableProps} from './useResizable'; + +export type {LayoutEdge}; + +/** The width a panel renders: pixels, a CSS length, or none. */ +export type RenderedPaneWidth = number | string | null | undefined; + +/** What the controller that sizes a pane hears from it. */ +export interface PaneListener { + /** + * In the commit that renders the pane at width 0, or away from it, before + * React changes the DOM. + */ + beforeChange(toCollapsed: boolean): void; + /** After every commit of the pane. */ + committed(): void; +} + +/** What a LayoutPanel lends the resize controller that sizes it. */ export interface ResizablePane { readonly element: HTMLElement; + /** The width the panel renders now. */ + renderedWidth(): RenderedPaneWidth; + /** The panel's edge that faces its content, where its ResizeHandle sits. */ + contentFacingEdge(): LayoutEdge; /** - * 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. + * The controller 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; + /** The controller that sizes the pane listens to it; null stops it. */ + listen(listener: PaneListener | null): void; } const lent = new WeakMap(); @@ -43,7 +72,7 @@ function notifyWatchers(): void { /** * 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. + * a factory looks for its pane again. Returns the function that stops it. */ export function watchResizablePanes(listener: () => void): () => void { watchers.add(listener); @@ -67,18 +96,26 @@ export function resizablePaneFor( } /** - * 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. + * Moves a region's hold from `held` to `next`, and returns `next`. A pane the + * region no longer sizes stops listening and opens. The held pane takes the + * region's collapse state, and focus inside it moves to `handle` as it + * collapses. A controller that sizes the pane passes its `listener`. */ export function handOverPane( held: ResizablePane | null, next: ResizablePane | null, + listener: PaneListener | null, isCollapsed: boolean, handle: HTMLElement | null, ): ResizablePane | null { if (next !== held) { + if (listener != null) { + held?.listen(null); + } held?.setCollapsed(false, null); + if (listener != null) { + next?.listen(listener); + } } next?.setCollapsed(isCollapsed, handle); return next; @@ -106,24 +143,103 @@ export function resizeHandleBeside( return null; } +/** + * The hook's side of a region. The LayoutPanel that takes the hook's own + * props hands its pane here as it mounts. The factory the region has, now + * or later, follows it. + */ +export interface RegionPaneSlot { + /** From the LayoutPanel: its pane, or null as it goes. */ + attach(pane: ResizablePane | null): void; + /** Calls `listener` with the pane now and whenever it changes. */ + follow(listener: (pane: ResizablePane | null) => void): () => void; + /** + * From the ResizeHandle given the props: whether a pointer drags it, so a + * drag that reopens the pane plays nothing while a key step plays. + */ + setPointerDragging(isDragging: boolean): void; + isPointerDragging(): boolean; +} + +export function createRegionPaneSlot(): RegionPaneSlot { + let current: ResizablePane | null = null; + let isDragging = false; + const listeners = new Set<(pane: ResizablePane | null) => void>(); + return { + setPointerDragging(next) { + isDragging = next; + }, + isPointerDragging: () => isDragging, + attach(pane) { + if (pane === current) { + return; + } + current = pane; + for (const listener of [...listeners]) { + listener(pane); + } + }, + follow(listener) { + listeners.add(listener); + listener(current); + return () => { + listeners.delete(listener); + }; + }, + }; +} + +/** + * Kept off the exported `ResizableProps`, like the hook's collapse controls: + * a hand-built object has no region, so its panel never slides. + */ +const propsRegions = new WeakMap(); + +/** The hook registers each `props` object it returns with its region. */ +export function registerPropsRegion( + props: ResizableProps, + slot: RegionPaneSlot, +): void { + propsRegions.set(props, slot); +} + +/** The region of the hook's own `props`, or undefined for a hand-built object. */ +export function regionOfProps( + props: ResizableProps, +): RegionPaneSlot | undefined { + return propsRegions.get(props); +} + /** LayoutPanel's side of the channel, one per panel. */ export interface ResizablePaneLink { - /** For useSyncExternalStore: whether a handle holds the panel collapsed. */ + /** For useSyncExternalStore: whether a controller holds the panel collapsed. */ readonly subscribe: (listener: () => void) => () => void; readonly isCollapsed: () => boolean; - /** The handle that named itself for focus to move to, if any. */ + /** The handle the controller named 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. + * From the panel: its element, lent beside its handle for a factory to + * find (`isFoundBeside`). Or the element is handed to a region. + * Null takes it back. Returns the pane lent, if any. */ - attach(element: HTMLElement | null): void; + attach( + element: HTMLElement | null, + isFoundBeside: boolean, + ): ResizablePane | null; + /** Before the DOM changes in a commit that renders width 0, or away from it. */ + beforeChange(toCollapsed: boolean): void; + /** After every commit of the panel, with the width it rendered and its content-facing edge. */ + committed(width: RenderedPaneWidth, edge: LayoutEdge): void; } export function createResizablePaneLink(): ResizablePaneLink { let isCollapsed = false; let focusTarget: HTMLElement | null = null; + let width: RenderedPaneWidth = undefined; + let edge: LayoutEdge = 'inline-end'; let attached: ResizablePane | null = null; + let isAttachedBeside = false; + let listener: PaneListener | null = null; const listeners = new Set<() => void>(); const setCollapsed = (next: boolean) => { @@ -145,35 +261,117 @@ export function createResizablePaneLink(): ResizablePaneLink { }, isCollapsed: () => isCollapsed, focusTarget: () => focusTarget, - attach(element) { - if (attached?.element === element) { - return; + attach(element, isFoundBeside) { + if (attached?.element === element && isAttachedBeside === isFoundBeside) { + return attached; } - const wasLent = attached != null; + const wasBeside = attached != null && isAttachedBeside; if (attached != null && lent.get(attached.element) === attached) { lent.delete(attached.element); } attached = null; + listener = null; + isAttachedBeside = isFoundBeside; if (element == null) { - // Taken back: no handle holds it collapsed any more. + // Taken back: no controller holds it collapsed any more. focusTarget = null; setCollapsed(false); } else { const pane: ResizablePane = { element, + renderedWidth: () => width, + contentFacingEdge: () => edge, setCollapsed(next, nextFocusTarget) { if (attached === pane) { focusTarget = nextFocusTarget; setCollapsed(next); } }, + listen(next) { + if (attached === pane) { + listener = next; + } + }, }; attached = pane; - lent.set(element, pane); + if (isFoundBeside) { + lent.set(element, pane); + } } - if (wasLent || element != null) { + if (wasBeside || (element != null && isFoundBeside)) { notifyWatchers(); } + return attached; + }, + beforeChange(toCollapsed) { + listener?.beforeChange(toCollapsed); + }, + committed(nextWidth, nextEdge) { + width = nextWidth; + edge = nextEdge; + listener?.committed(); + }, + }; +} + +/** + * The edge reveal a resize controller plays for its pane. It measures before + * the DOM changes and plays on the pane's first commit at a new size. It runs + * on the controller's own animated-layout handle over the pane's row, with no + * copy. + */ +export interface PaneReveal { + /** Before the DOM changes: measures `pane` for its reveal from `edge`. */ + measure(pane: ResizablePane, edge: LayoutEdge): void; + /** After a commit of the pane: the one that renders its new size plays. */ + committed(pane: ResizablePane): void; + /** Lands a measured or running reveal at once. */ + land(): void; +} + +type EdgeReport = ReturnType; + +export function createPaneReveal(getRow: () => Element | null): PaneReveal { + // Measured, waiting for the pane to render its new size; then played. + let pending: { + report: EdgeReport; + pane: ResizablePane; + widthBefore: RenderedPaneWidth; + } | null = null; + let played: EdgeReport | null = null; + const layout = createAnimatedLayout(getRow); + + const land = () => { + pending?.report.cancel(); + pending = null; + played?.cancel(); + played = null; + }; + + return { + measure(pane, edge) { + // A measured change the pane never rendered is stale; a running one + // reverses from what it shows. + pending?.report.cancel(); + pending = null; + const element = pane.element; + const report = reportLayoutEdge(layout, () => element, {edge}); + report.measureBefore(); + pending = {report, pane, widthBefore: pane.renderedWidth()}; + }, + committed(pane) { + if ( + pending == null || + pending.pane !== pane || + pane.renderedWidth() === pending.widthBefore + ) { + return; + } + const {report} = pending; + pending = null; + played = report; + report.animate(); }, + land, }; } diff --git a/packages/core/src/Resizable/useResizable.ts b/packages/core/src/Resizable/useResizable.ts index ff41d69de9f1c..76a9b91a54131 100644 --- a/packages/core/src/Resizable/useResizable.ts +++ b/packages/core/src/Resizable/useResizable.ts @@ -31,6 +31,11 @@ import { type ResizableController, type ResizableSnapshot, } from './createResizable'; +import { + createRegionPaneSlot, + registerPropsRegion, + type RegionPaneSlot, +} from './resizablePane'; import { DEFAULT_COLLAPSED_SIZE, type ResizableDirection, @@ -218,8 +223,10 @@ const EMPTY_SNAPS: number[] = []; * factory's region through them. */ function useSingleResizable(config: UseResizableSingleConfig): ResizableRegion { + // The region's pane: the LayoutPanel that takes these props hands it over. + const [regionSlot] = useState(createRegionPaneSlot); const {getResizable, size, isCollapsed, snapshot, collapse, expand, resize} = - useResizableController(config); + useResizableController(config, regionSlot); // The region's side of the handle's gesture, read at each input, so a whole // drag reaches the factory as it is now. const model = useCallback(() => { @@ -258,6 +265,9 @@ function useSingleResizable(config: UseResizableSingleConfig): ResizableRegion { _isResizableProps: true, }; collapseControls.set(props, {collapse, expand}); + // The LayoutPanel that takes these props hands its pane to the region, and + // the ResizeHandle that takes them says when a pointer drags it. + registerPropsRegion(props, regionSlot); // Without a configured maximum a measured extent stands in, only once it // is a real measurement (an observed container, or the window). if (snapshot.effectiveMaxPx != null) { @@ -520,6 +530,7 @@ interface ResizableReading { */ function useResizableController( options: CreateResizableOptions, + regionSlot?: RegionPaneSlot, ): ResizableReading { const optionsRef = useRef(options); optionsRef.current = options; @@ -527,6 +538,19 @@ function useResizableController( const [serverInitial] = useState(() => readInitialResizableState(options, 'server'), ); + // Every start of the factory, the commit's and an early call's, follows the + // region a LayoutPanel lends. + const start = useCallback( + (passed: CreateResizableOptions): ResizableController => { + const resizable = createResizableAs('useResizable', null, passed); + if (regionSlot != null) { + // destroy() stops following the region. + readResizableInternals(resizable)?.followRegion(regionSlot); + } + return resizable; + }, + [regionSlot], + ); // The hook keeps one factory. After an unmount or an `` hide, the // same factory takes the page back (`resume`). const follow = useCallback(() => { @@ -536,9 +560,9 @@ function useResizableController( return; } const passed = optionsRef.current; - const resizable = createResizableAs('useResizable', null, passed); + const resizable = start(passed); slot.set({resizable, passed}); - }, [slot]); + }, [slot, start]); useIsomorphicLayoutEffect(() => { follow(); }); @@ -613,11 +637,11 @@ function useResizableController( return entry.resizable; } const passed = optionsRef.current; - const resizable = createResizableAs('useResizable', null, passed); + const resizable = start(passed); readResizableInternals(resizable)?.suspend(); slot.set({resizable, passed}); return resizable; - }, [slot]); + }, [slot, start]); const collapse = useCallback(() => { ensureResizable().collapse(); }, [ensureResizable]); diff --git a/packages/core/src/motion/animatedLayout.boundary.test.ts b/packages/core/src/motion/animatedLayout.boundary.test.ts index 7e426f5977bcc..02f624ffefdd1 100644 --- a/packages/core/src/motion/animatedLayout.boundary.test.ts +++ b/packages/core/src/motion/animatedLayout.boundary.test.ts @@ -9,7 +9,8 @@ * elements only through parentElement and the element siblings on either * side (and firstElementChild under `display: contents`), stopping at the * band around the visible part, the reporting functions are - * not public, and only the hook and the adopting components use it + * not public, and only the hook, the adopting components and the resize + * controller's edge reveal use it * @position Testing; the real-browser half of INV12's verification is in * __tests__/animatedLayout.interaction.browser.spec.ts */ @@ -150,6 +151,7 @@ describe('architecture:react-component-runtime INV12 source boundary', () => { expect(users).toEqual([ 'AppShell/AppShell.tsx', 'Collapsible/Collapsible.tsx', + 'Resizable/resizablePane.ts', 'SideNav/railMotion.ts', 'Table/plugins/rowExpansion/useTableRowExpansion.tsx', 'TreeList/TreeList.tsx', diff --git a/packages/core/src/motion/animatedLayout.ts b/packages/core/src/motion/animatedLayout.ts index 3364d7157eb12..8e5226500e1a7 100644 --- a/packages/core/src/motion/animatedLayout.ts +++ b/packages/core/src/motion/animatedLayout.ts @@ -2810,12 +2810,56 @@ const HOLD_PROPERTIES = { ], } as const; +/** + * The inside of an element as it was laid out: its padding along an axis, its + * overflow and its scroll offset. A size change can change them in the same + * commit, as a LayoutPanel at width 0 drops its padding and stops scrolling. + */ +interface LaidOutInside { + /** Its padding at the axis's start and end. */ + readonly gutters: readonly [string, string]; + /** Its overflow across and down. */ + readonly overflow: readonly [string, string]; + /** Its scroll offset across and down. */ + readonly scroll: readonly [number, number]; +} + +function insideOf( + element: HTMLElement, + axis: 'inline' | 'block', +): LaidOutInside { + const style = getComputedStyle(element); + return { + gutters: [ + style.getPropertyValue(`padding-${axis}-start`), + style.getPropertyValue(`padding-${axis}-end`), + ], + overflow: [ + style.getPropertyValue('overflow-x'), + style.getPropertyValue('overflow-y'), + ], + scroll: [element.scrollLeft, element.scrollTop], + }; +} + +function restoreScroll(element: HTMLElement, inside: LaidOutInside): void { + const [across, down] = inside.scroll; + if (element.scrollLeft !== across) { + element.scrollLeft = across; + } + if (element.scrollTop !== down) { + element.scrollTop = down; + } +} + /** * Holds `element` at `extent` own px along `edge`'s axis while it conceals to * `keep`, freeing the difference by the margin on that edge's side, so what - * lies beyond the edge stays where the new size puts it. Returns what puts - * back every value it set, or null when no margin frees it (the content on - * that side did not stay put), having set nothing. + * lies beyond the edge stays where the new size puts it. Its padding, + * overflow and scroll offset stay as `inside` measured them, so nothing in it + * moves while it is clipped. Returns what puts back every value it set. + * When no margin frees it, as the content on that side did not stay put, it + * sets nothing and returns null. */ function holdExtent( element: HTMLElement, @@ -2823,10 +2867,21 @@ function holdExtent( edge: LayoutEdge, extent: number, keep: number, + inside: LaidOutInside, ): (() => void) | null { const axis = edge.startsWith('inline') ? 'inline' : 'block'; const margin = `margin-${edge}`; - const properties = [...HOLD_PROPERTIES[axis], margin]; + const insideProperties = [ + [`padding-${axis}-start`, inside.gutters[0]], + [`padding-${axis}-end`, inside.gutters[1]], + ['overflow-x', inside.overflow[0]], + ['overflow-y', inside.overflow[1]], + ] as const; + const properties = [ + ...HOLD_PROPERTIES[axis], + margin, + ...insideProperties.map(([property]) => property), + ]; const prior = properties.map( property => [ @@ -2857,6 +2912,11 @@ function holdExtent( element.style.setProperty('flex-basis', 'auto', 'important'); element.style.setProperty('flex-grow', '0', 'important'); element.style.setProperty('flex-shrink', '0', 'important'); + for (const [property, value] of insideProperties) { + if (value !== '') { + element.style.setProperty(property, value, 'important'); + } + } element.style.setProperty(margin, `${keep - extent}px`, 'important'); if (beyond != null && before != null) { // One correction from the measured result (a gap, the element's own @@ -2882,6 +2942,7 @@ function holdExtent( return null; } } + restoreScroll(element, inside); return restore; } @@ -2895,7 +2956,9 @@ function holdExtent( * the extent the element showed. A smaller one conceals: the element is held * at the extent it was laid out at, the difference freed by the margin on * that edge's side so the content beyond it stays where the new size puts it, - * and clipped down to its new extent; the hold goes when the motion ends. The + * and clipped down to its new extent; the hold goes when the motion ends. + * Its padding, overflow and scroll offset stay as laid out while it is held, + * so nothing inside it jumps when the new size changes them. The * moving edge rides the glide of the content on that side, frame by frame, * and a change made while it runs starts from the extent it shows. It slides: * nothing fades. Under Reduce Motion with a coarse primary pointer a reveal @@ -2912,7 +2975,13 @@ export function reportLayoutEdge( * What the element showed, its laid-out extent and its box, before the * change. */ - let measured: {shown: number; box: number; rect: DOMRect} | null = null; + let measured: { + shown: number; + box: number; + rect: DOMRect; + inside: LaidOutInside; + } | null = null; + const axis = edge.startsWith('inline') ? 'inline' : 'block'; const release = (element: HTMLElement | null) => { if (element == null) { return; @@ -2928,12 +2997,15 @@ export function reportLayoutEdge( const running = resizeReveals.get(element); const elapsed = Number(running?.animation?.currentTime); const rect = element.getBoundingClientRect(); + // A running conceal holds the inside as it was laid out. + const inside = insideOf(element, axis); measured = running == null ? { shown: extentOf(element, edge), box: extentOf(element, edge), rect, + inside, } : { shown: running.shownAt( @@ -2941,6 +3013,7 @@ export function reportLayoutEdge( ), box: running.box, rect, + inside, }; running?.release(); } @@ -2954,6 +3027,8 @@ export function reportLayoutEdge( if (handle == null || element == null || before == null) { return; } + // A conceal released to reverse can drop the scroll offset. + restoreScroll(element, before.inside); const area = areaOf(handle, element); if (area == null) { return; @@ -2994,7 +3069,7 @@ export function reportLayoutEdge( // the one it was laid out at, held while it runs. const box = isConceal ? Math.max(before.box, before.shown) : after; const restore = isConceal - ? holdExtent(element, area, edge, box, after) + ? holdExtent(element, area, edge, box, after, before.inside) : () => {}; if (restore == null) { return; From a33ce9db49f8523e759c107950a37dd7cdb361ef Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Wed, 4 Nov 2026 09:08:23 -0800 Subject: [PATCH 03/67] docs(LayoutPanel): a panel given the hook's own value slides closed and open The `resizable` description, in English, Chinese and dense form, says that a panel given the hook's own value slides closed and open from the edge facing its ResizeHandle, and that a hand-built object does not. A `[feat]` changeset describes the slide. --- .changeset/layout-panel-slides-on-collapse.md | 7 +++++++ packages/core/src/Layout/LayoutPanel.doc.mjs | 6 +++--- 2 files changed, 10 insertions(+), 3 deletions(-) create mode 100644 .changeset/layout-panel-slides-on-collapse.md diff --git a/.changeset/layout-panel-slides-on-collapse.md b/.changeset/layout-panel-slides-on-collapse.md new file mode 100644 index 0000000000000..33cb88601f215 --- /dev/null +++ b/.changeset/layout-panel-slides-on-collapse.md @@ -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 diff --git a/packages/core/src/Layout/LayoutPanel.doc.mjs b/packages/core/src/Layout/LayoutPanel.doc.mjs index 8496c0b0a3d21..2ef82b18d74f8 100644 --- a/packages/core/src/Layout/LayoutPanel.doc.mjs +++ b/packages/core/src/Layout/LayoutPanel.doc.mjs @@ -71,7 +71,7 @@ export const docs = { name: 'resizable', type: 'ResizableRegion | ResizableProps', description: - "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.", + "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. With the hook's own value, the panel slides closed and open from the edge facing its ResizeHandle; a hand-built object does not slide. Either way, focus inside the panel moves to its ResizeHandle as it collapses, and the panel is inert while collapsed.", }, ], }; @@ -125,7 +125,7 @@ export const docsZh = { name: 'resizable', type: 'ResizableRegion | ResizableProps', description: - "useResizable() 的返回值或其 props。提供时面板宽度由 hook 驱动,应在面板旁放置 ResizeHandle。其中携带区域的轴向('horizontal' | 'vertical'),必须与相邻 ResizeHandle 的 direction 一致。折叠时面板内的焦点会移到其 ResizeHandle 上,折叠期间面板为 inert。", + "useResizable() 的返回值或其 props。提供时面板宽度由 hook 驱动,应在面板旁放置 ResizeHandle。其中携带区域的轴向('horizontal' | 'vertical'),必须与相邻 ResizeHandle 的 direction 一致。使用 hook 自身返回的值时,面板从朝向其 ResizeHandle 的边缘滑动关闭和打开;手动构造的对象不会滑动。两种情况下,折叠时面板内的焦点都会移到其 ResizeHandle 上,折叠期间面板为 inert。", }, ], }; @@ -147,6 +147,6 @@ export const docsDense = { 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: - 'useResizable() output or its props. Hook drives panel width; place ResizeHandle adjacent. Focus inside moves to the handle on collapse; inert while collapsed.', + 'useResizable() output or its props. Hook drives panel width; place ResizeHandle adjacent. Hook values slide; hand-built ones do not. Focus inside moves to the handle on collapse; inert while collapsed.', }, }; From 980c4e6489af6f7d4a76f7c1c39f4c42cc0a356a Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Wed, 4 Nov 2026 09:08:52 -0800 Subject: [PATCH 04/67] test(Resizable): a start and an end LayoutPanel slide with their handle in Chromium and WebKit Real-browser proof on new stories: a start and an end panel given the hook's value slide closed and open with the content beside them, through a toggle, a controlled isCollapsed, a double-click, the grow arrow and Enter. A drag reopens a pane under the pointer, and a hand-built object collapses without a slide. Focus moves to the handle as a pane collapses. The slide proof captures its moments by seeking the paused motion. Nothing inside a sliding pane moves, and a scrolled pane keeps its scroll offset as it slides closed. The pane's shown edge stays where the motion leaves it beside the handle: a panel without a divider overlaps its handle by its collapse spacing only while open. --- apps/storybook/stories/Resizable.stories.tsx | 204 ++++++ .../paneSlide.interaction.browser.spec.ts | 655 ++++++++++++++++++ 2 files changed, 859 insertions(+) create mode 100644 packages/core/src/Resizable/__tests__/paneSlide.interaction.browser.spec.ts diff --git a/apps/storybook/stories/Resizable.stories.tsx b/apps/storybook/stories/Resizable.stories.tsx index 55d39bce2385e..a907a2b5a5c3c 100644 --- a/apps/storybook/stories/Resizable.stories.tsx +++ b/apps/storybook/stories/Resizable.stories.tsx @@ -840,3 +840,207 @@ export const CollapsingScrollPanelFixture: Story = { tags: ['!autodocs', 'no-visual'], render: () => , }; + +/** + * 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 = ( + + + Panel +