diff --git a/.changeset/layout-panel-slides-on-collapse.md b/.changeset/layout-panel-slides-on-collapse.md new file mode 100644 index 0000000000000..a4eb55d409d38 --- /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 lift 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/.changeset/resizable-limits-rubber-band.md b/.changeset/resizable-limits-rubber-band.md new file mode 100644 index 0000000000000..9761c58d5c5be --- /dev/null +++ b/.changeset/resizable-limits-rubber-band.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[feat] A `ResizeHandle` that any pointer pulls past the maximum, or under the minimum, of a region whose pane a `LayoutPanel` renders now moves the handle with resistance, at most about 8 px past the limit and over the content beside it, while the panes keep their sizes, and the handle springs back to the limit after the lift. A panel sized by the region's `size` alone stops at the limit. A collapsible region no longer collapses mid-drag: only the lift collapses it, when the pointer is past `collapsedSize`, or released fast enough to come to rest past it, and a `LayoutPanel`'s pane slides shut on a spring from the release velocity. Any other lift under the minimum returns to the minimum. A pointer that grabs the handle again catches it where it shows. Arrow keys still stop at the limit. Reduce Motion lands both at once. + +@vjeux diff --git a/apps/storybook/stories/Resizable.stories.tsx b/apps/storybook/stories/Resizable.stories.tsx index 55d39bce2385e..ef2c847cef1bd 100644 --- a/apps/storybook/stories/Resizable.stories.tsx +++ b/apps/storybook/stories/Resizable.stories.tsx @@ -1,6 +1,6 @@ // Copyright (c) Meta Platforms, Inc. and affiliates. -import {useRef, useState} from 'react'; +import {useLayoutEffect, useRef, useState} from 'react'; import type {Meta, StoryObj} from '@storybook/react'; import * as stylex from '@stylexjs/stylex'; import {colorVars, radiusVars} from '@astryxdesign/core/theme/tokens.stylex'; @@ -27,6 +27,8 @@ const ps = stylex.create({ overflow: 'hidden', }, sz: {opacity: 0.6, fontSize: 12, fontFamily: 'monospace'}, + // NOT FOR LANDING: two splits fit one phone screen. + compact: {height: 300}, }); const meta: Meta = { @@ -840,3 +842,345 @@ 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, + snaps, + isInitiallyCollapsed = false, + maxSize = 320, + band, + isCompact = false, +}: { + side: 'start' | 'end'; + lineCount?: number; + snaps?: number[]; + isInitiallyCollapsed?: boolean; + maxSize?: number; + band?: 'handle' | 'grip'; + isCompact?: boolean; +}) { + const resizable = useResizable({ + defaultSize: 200, + minSize: 120, + maxSize, + collapsible: true, + snaps, + defaultIsCollapsed: isInitiallyCollapsed, + }); + const pane = ( + + + Panel + + + ) : ( + + + + ); + 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 the close a lift past the collapse threshold makes, 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(false); + expect(reveals).toHaveLength(0); + fireEvent.pointerUp(grabZone(), {pointerId: 1, clientX: 110}); + expect(propsRegion?.isCollapsed).toBe(true); + expect(reveals).toHaveLength(1); + expect(reveals[0].animate).toHaveBeenCalledTimes(1); + fireEvent.pointerDown(grabZone(), {pointerId: 1, clientX: 110}); + 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('reads no layout while a drag moves inside the limits', () => { + render(); + // The role query reads computed styles, so it runs before the spies. + const zone = grabZone(); + fireEvent.pointerDown(zone, {pointerId: 1, clientX: 300}); + const widths = vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get'); + const styles = vi.spyOn(window, 'getComputedStyle'); + for (const clientX of [290, 280, 270, 260]) { + fireEvent.pointerMove(zone, {pointerId: 1, clientX}); + } + expect(widths).not.toHaveBeenCalled(); + expect(styles).not.toHaveBeenCalled(); + widths.mockRestore(); + styles.mockRestore(); + fireEvent.pointerUp(zone, {pointerId: 1, clientX: 260}); + }); + + it('holds a pane still sliding shut where it shows, and opens it at the finger once the finger passes the minimum', () => { + render(); + const zone = grabZone(); + fireEvent.pointerDown(zone, {pointerId: 1, clientX: 300}); + fireEvent.pointerMove(zone, {pointerId: 1, clientX: 110}); + fireEvent.pointerUp(zone, {pointerId: 1, clientX: 110}); + expect(propsRegion?.isCollapsed).toBe(true); + // At once, while it slides shut: the lift left the edge near 43 px, the + // 50 px minimum less the band. The finger holds it there, still shut. + fireEvent.pointerDown(zone, {pointerId: 2, clientX: 110}); + fireEvent.pointerMove(zone, {pointerId: 2, clientX: 113}); + expect(propsRegion?.isCollapsed).toBe(true); + fireEvent.pointerMove(zone, {pointerId: 2, clientX: 140}); + // The finger passed the minimum 30 px from where it caught the edge. + expect(propsRegion?.isCollapsed).toBe(false); + expect(propsRegion?.size).toBeGreaterThan(65); + expect(propsRegion?.size).toBeLessThan(80); + fireEvent.pointerUp(zone, {pointerId: 2, clientX: 140}); + }); + + it('opens a held closing pane to its minimum on a lift above the collapse threshold', () => { + render(); + const zone = grabZone(); + fireEvent.pointerDown(zone, {pointerId: 1, clientX: 300}); + fireEvent.pointerMove(zone, {pointerId: 1, clientX: 110}); + fireEvent.pointerUp(zone, {pointerId: 1, clientX: 110}); + fireEvent.pointerDown(zone, {pointerId: 2, clientX: 110}); + fireEvent.pointerMove(zone, {pointerId: 2, clientX: 112}); + fireEvent.pointerUp(zone, {pointerId: 2, clientX: 112}); + expect(propsRegion?.isCollapsed).toBe(false); + expect(propsRegion?.size).toBe(50); + }); + + it('springs the drawn handle back by the next frame when a controlled owner keeps the pane open on a closing lift', async () => { + const keepsOpen = vi.fn(); + // At its 50 px minimum already, so the lift restores no other size. + render( + , + ); + const zone = grabZone(); + fireEvent.pointerDown(zone, {pointerId: 1, clientX: 300}); + // 190 px toward closed: past the minimum, where the handle bands, and + // under the collapse threshold. + fireEvent.pointerMove(zone, {pointerId: 1, clientX: 110}); + const drawn = separator().style.translate; + expect(drawn).not.toBe(''); + fireEvent.pointerUp(zone, {pointerId: 1, clientX: 110}); + expect(keepsOpen).toHaveBeenCalledWith(true); + expect(propsRegion?.isCollapsed).toBe(false); + await new Promise(resolve => setTimeout(resolve, 50)); + expect(separator().style.translate).not.toBe(drawn); + }); + + it('keeps a held closing pane shut on a lift under the collapse threshold', () => { + render(); + const zone = grabZone(); + fireEvent.pointerDown(zone, {pointerId: 1, clientX: 300}); + fireEvent.pointerMove(zone, {pointerId: 1, clientX: 110}); + fireEvent.pointerUp(zone, {pointerId: 1, clientX: 110}); + fireEvent.pointerDown(zone, {pointerId: 2, clientX: 110}); + // Down to about 13 px, under the 40 px threshold. + fireEvent.pointerMove(zone, {pointerId: 2, clientX: 80}); + fireEvent.pointerUp(zone, {pointerId: 2, clientX: 80}); + expect(propsRegion?.isCollapsed).toBe(true); + }); + + it('catches a pane sliding shut between snap points where it shows, and follows the finger to a snap', () => { + render(); + const zone = grabZone(); + fireEvent.pointerDown(zone, {pointerId: 1, clientX: 300}); + fireEvent.pointerMove(zone, {pointerId: 1, clientX: 110}); + fireEvent.pointerUp(zone, {pointerId: 1, clientX: 110}); + expect(propsRegion?.isCollapsed).toBe(true); + // The lift left the edge near 42 px. Mid-slide, the handle shows 133 px + // further out, so the pane shows about 175 px. + vi.spyOn(separator(), 'getBoundingClientRect').mockReturnValue( + DOMRect.fromRect({x: 133, y: 0, width: 0, height: 0}), + ); + fireEvent.pointerDown(zone, {pointerId: 2, clientX: 110}); + fireEvent.pointerMove(zone, {pointerId: 2, clientX: 120}); + // The edge followed the finger 10 px from where it showed, between snaps. + expect(propsRegion?.size).toBeGreaterThan(180); + expect(propsRegion?.size).toBeLessThan(190); + // Past a snap point, the snaps hold again. + fireEvent.pointerMove(zone, {pointerId: 2, clientX: 245}); + expect(propsRegion?.size).toBe(300); + fireEvent.pointerUp(zone, {pointerId: 2, clientX: 245}); + expect(propsRegion?.size).toBe(300); + }); + + it('lands a caught pane on the snap nearest its edge when the finger lifts between snaps', () => { + render(); + const zone = grabZone(); + fireEvent.pointerDown(zone, {pointerId: 1, clientX: 300}); + fireEvent.pointerMove(zone, {pointerId: 1, clientX: 110}); + fireEvent.pointerUp(zone, {pointerId: 1, clientX: 110}); + vi.spyOn(separator(), 'getBoundingClientRect').mockReturnValue( + DOMRect.fromRect({x: 133, y: 0, width: 0, height: 0}), + ); + fireEvent.pointerDown(zone, {pointerId: 2, clientX: 110}); + fireEvent.pointerMove(zone, {pointerId: 2, clientX: 120}); + fireEvent.pointerUp(zone, {pointerId: 2, clientX: 120}); + expect(propsRegion?.isCollapsed).toBe(false); + expect(propsRegion?.size).toBe(150); + }); + + 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(false); + fireEvent.pointerUp(grabZone(), {pointerId: 1, clientX: 100}); + expect(propsRegion?.isCollapsed).toBe(true); + expect(reveals).toHaveLength(7); + expect(reveals[6].animate).toHaveBeenCalledTimes(1); + fireEvent.pointerDown(grabZone(), {pointerId: 1, clientX: 100}); + 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/Resizable/resizablePane.tempo.test.ts b/packages/core/src/Resizable/resizablePane.tempo.test.ts new file mode 100644 index 0000000000000..4e015ee328003 --- /dev/null +++ b/packages/core/src/Resizable/resizablePane.tempo.test.ts @@ -0,0 +1,57 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file resizablePane.tempo.test.ts + * @input resizablePane.ts, the shared springs table, a stubbed computed style + * that writes a theme's tempo + * @output Proof that a release slide plays the settle spring at the theme's + * tempo + * @position Testing for /packages/core/src/Resizable/resizablePane.ts + */ + +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {settlingDuration, toSpring} from '../motion/spring'; +import {registerSpringsTable, springs} from '../motion/uikit'; +import {slideNextOnRelease} from './resizablePane'; + +registerSpringsTable(); + +const TEMPO_PROPERTY = '--_astryx-motion-tempo'; + +/** The tempo the stubbed theme writes; empty where no theme sets one. */ +let tempo: string; + +beforeEach(() => { + tempo = ''; + vi.spyOn(window, 'getComputedStyle').mockImplementation( + () => + ({ + getPropertyValue: (name: string) => + name === TEMPO_PROPERTY ? tempo : '', + }) as unknown as CSSStyleDeclaration, + ); +}); + +afterEach(() => { + vi.restoreAllMocks(); + document.body.innerHTML = ''; +}); + +function row(): HTMLElement { + const element = document.createElement('div'); + document.body.append(element); + return element; +} + +describe("a release slide at the theme's tempo", () => { + it('plays the settle spring tempo times as long', () => { + const settleMs = settlingDuration(toSpring(springs.settle)) * 1000; + const atOne = slideNextOnRelease(row(), 0, 100); + tempo = '1.5'; + const atSlow = slideNextOnRelease(row(), 0, 100); + + expect(atOne?.(settleMs * 1.2)).toBe(0); + expect(atSlow?.(settleMs * 1.2)).toBeGreaterThan(0); + expect(atSlow?.(settleMs * 1.6)).toBe(0); + }); +}); diff --git a/packages/core/src/Resizable/resizablePane.ts b/packages/core/src/Resizable/resizablePane.ts index b2591e36aebed..c056662e7e05f 100644 --- a/packages/core/src/Resizable/resizablePane.ts +++ b/packages/core/src/Resizable/resizablePane.ts @@ -2,24 +2,61 @@ /** * @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. It also carries that controller's + * edge reveal 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, + glideNextChange, + glideOf, + landLayoutGlide, + type LayoutEdge, + reportLayoutEdge, + springGlide, +} from '../motion/animatedLayout'; +import {animatorFor, type MotionHandle} from '../motion/animator'; +import {resolveMotion} from '../motion/resolveMotion'; +import type {SpringDescription} from '../motion/spring'; +import {springs} from '../motion/uikit/springs'; +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 +80,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 +104,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 +151,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 +269,424 @@ 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; + // What glides beside the pane lands with it, so no motion plays over the + // layout that replaces it. + for (const child of getRow()?.children ?? []) { + landLayoutGlide(child); + } + }; + + 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, + }; +} + +/** + * The next slide in `row` plays the settle spring at the theme's tempo, not + * the theme's curve. + * It starts from a release `velocity` (px per second toward the end) over + * `distance` px. Returns the share of the distance still to go, by ms since + * the slide began, or null when nothing slides. + */ +export function slideNextOnRelease( + row: Element, + velocity: number, + distance: number, +): ((elapsedMs: number) => number) | null { + if (!(distance > 0)) { + return null; + } + const progress = springGlide( + resolveMotion(row, springs.settle), + velocity / distance, + ); + glideNextChange(row, progress); + return elapsedMs => + elapsedMs >= progress.durationMs ? 0 : progress.remaining(elapsedMs); +} + +/** + * NOT FOR LANDING: the try-it stories' choices. `closeLine` draws an accent + * line where the edge will land. `snapOnLift` lets a drag move freely and + * settles on the nearest snap at the lift. `expandFeedback` shows a drag out + * of a collapsed pane: a peek of the edge, or the line at the open size. + * `band: 'grip'` bands the grip tab alone (card 81e). + */ +export interface ResizeTryIt { + band?: 'handle' | 'grip'; + closeLine?: boolean; + snapOnLift?: boolean; + expandFeedback?: 'peek' | 'line'; +} + +/** NOT FOR LANDING: the choices the try-it stories set. */ +export function tryIt(): ResizeTryIt { + return ( + (globalThis as {__astryxResizeTryIt?: ResizeTryIt}).__astryxResizeTryIt ?? + {} + ); +} + +const targetLines = new WeakMap< + HTMLElement, + {line: HTMLElement; far: number; sign: number; isInline: boolean} +>(); + +/** + * NOT FOR LANDING: an accent line, from the reorder insertion line's tokens, + * where the pane's edge will land: `size` px from the pane's far side. Null + * takes it away. The pane is measured once, when the line first shows. + */ +export function showTargetLine(pane: ResizablePane, size: number | null): void { + const element = pane.element; + let shown = targetLines.get(element); + if (size == null || tryIt().closeLine !== true) { + shown?.line.remove(); + targetLines.delete(element); + return; + } + if (shown == null) { + const edge = pane.contentFacingEdge(); + const isInline = edge.startsWith('inline'); + const isTowardEnd = edge.endsWith('end'); + const isRtl = + isInline && + element.ownerDocument.defaultView?.getComputedStyle(element).direction === + 'rtl'; + const isForward = isInline ? isTowardEnd !== isRtl : isTowardEnd; + const rect = element.getBoundingClientRect(); + const far = isInline + ? isForward + ? rect.left + : rect.right + : isForward + ? rect.top + : rect.bottom; + const line = element.ownerDocument.createElement('div'); + Object.assign(line.style, { + position: 'fixed', + pointerEvents: 'none', + zIndex: '2', + background: 'var(--color-accent)', + borderRadius: 'var(--radius-full)', + ...(isInline + ? { + top: `${rect.top}px`, + height: `${rect.height}px`, + width: 'var(--spacing-0-5)', + } + : { + left: `${rect.left}px`, + width: `${rect.width}px`, + height: 'var(--spacing-0-5)', + }), + }); + element.ownerDocument.body.append(line); + shown = {line, far, sign: isForward ? 1 : -1, isInline}; + targetLines.set(element, shown); + } + const at = `calc(${shown.far + shown.sign * size}px - var(--spacing-0-5) / 2)`; + if (shown.isInline) { + shown.line.style.left = at; + } else { + shown.line.style.top = at; + } +} + +/** + * Reads the size the pane's edge shows from its handle's drawn middle, + * glides included. Measured while the pane shows `size`. + */ +export function shownSizeReader( + pane: ResizablePane, + size: number, +): (() => number) | null { + const element = pane.element; + const handle = resizeHandleBeside(element, null); + if (handle == null) { + return null; + } + const edge = pane.contentFacingEdge(); + const isInline = edge.startsWith('inline'); + const isRtl = + isInline && + element.ownerDocument.defaultView?.getComputedStyle(element).direction === + 'rtl'; + const isForward = isInline + ? edge.endsWith('end') !== isRtl + : edge.endsWith('end'); + const rect = element.getBoundingClientRect(); + const far = isInline + ? isForward + ? rect.left + : rect.right + : isForward + ? rect.top + : rect.bottom; + const sign = isForward ? 1 : -1; + const middle = () => { + const box = handle.getBoundingClientRect(); + return isInline ? box.left + box.width / 2 : box.top + box.height / 2; + }; + // The handle's middle stands this far past the pane's shown edge. + const offset = sign * (middle() - far) - size; + return () => Math.max(sign * (middle() - far) - offset, 0); +} + +/** A motion a script plays: not a CSS transition or a CSS animation. */ +export function isScriptMotion(motion: Animation): boolean { + return !('transitionProperty' in motion) && !('animationName' in motion); +} + +/** + * The motions a slide of `pane` plays now: what glides beside it, and its own + * edge's motion on the same clock. None once the slide lands. + */ +export function slideMotionsOf(pane: ResizablePane): Animation[] { + const element = pane.element; + const beside = [...(element.parentElement?.children ?? [])] + .filter(child => child !== element) + .map(glideOf) + .filter((motion): motion is Animation => motion != null); + const durationOf = (motion: Animation) => + Number(motion.effect?.getComputedTiming().duration ?? 0); + const durationMs = beside.length > 0 ? durationOf(beside[0]) : null; + const own = + typeof element.getAnimations === 'function' + ? element + .getAnimations() + .filter( + motion => + isScriptMotion(motion) && + (durationMs == null || + Math.abs(durationOf(motion) - durationMs) < 1), + ) + : []; + return [...own, ...beside]; +} + +/** A pull past a limit, drawn on a pane a LayoutPanel renders. */ +export interface OvershootDrawing { + /** Draws the handle `amount` px past its limit: positive past the maximum. */ + draw(amount: number): void; + /** + * Springs the drawn handle back to the limit on the handle's own animator, + * on `spring` from a release `velocity`. The velocity is the amount's own + * rate in px per second, positive past the maximum. Returns false when + * nothing is drawn. + */ + springBack(spring: SpringDescription, velocity: number): boolean; + /** How far past its limit the handle shows now, a spring-back included. */ + shown(): number; + /** Drops the drawing at once. */ + clear(): void; +} + +/** + * Draws a pull past a limit with no layout change. The handle alone moves + * past the limit, over the content beside it, so the panes keep their sizes + * and nothing reflows. The geometry is measured once, as the overshoot + * starts. + */ +export function createOvershootDrawing(pane: ResizablePane): OvershootDrawing { + const element = pane.element; + let geometry: { + sign: number; + axis: 'x' | 'y'; + handle: HTMLElement | null; + } | null = null; + let drawn = 0; + let springing: MotionHandle | null = null; + + const measure = () => { + if (geometry != null) { + return geometry; + } + const edge = pane.contentFacingEdge(); + const isInline = edge.startsWith('inline'); + const isRtl = + isInline && + element.ownerDocument.defaultView?.getComputedStyle(element).direction === + 'rtl'; + const handle = resizeHandleBeside(element, null); + // NOT FOR LANDING: card 81e bands the grip tab alone, over a still + // handle line. A story's wrapper picks it; the page-global switch too. + const band = + element.closest('[data-try-it-band]')?.getAttribute('data-try-it-band') ?? + tryIt().band; + const grip = + band === 'grip' + ? handle?.querySelector('.astryx-resize-handle-pill') + : null; + geometry = { + sign: (edge.endsWith('end') ? 1 : -1) * (isRtl ? -1 : 1), + axis: isInline ? 'x' : 'y', + handle: grip ?? handle, + }; + return geometry; + }; + const animator = () => { + const handle = geometry?.handle; + return handle == null ? null : animatorFor(handle); + }; + + return { + draw(amount) { + const isAtRest = drawn === 0 && springing == null; + if (isAtRest && amount === 0) { + return; + } + if (isAtRest) { + // A new overshoot measures once: the layout can change between pulls. + geometry = null; + } + springing = null; + drawn = amount; + const {sign, axis} = measure(); + animator()?.set({[axis]: sign * amount}); + }, + springBack(spring, velocity) { + const motion = animator(); + if (drawn === 0 || motion == null || geometry == null) { + return false; + } + const {sign, axis} = geometry; + drawn = 0; + const played = motion.animate( + {[axis]: 0}, + {spring, velocity: {[axis]: sign * velocity}}, + ); + springing = played; + void played.finished.then(() => { + if (springing === played) { + springing = null; + } + }); + return true; + }, + shown() { + const motion = animator(); + if (springing == null || motion == null || geometry == null) { + return drawn; + } + return geometry.sign * motion.get(geometry.axis); + }, + clear() { + if (geometry != null && (drawn !== 0 || springing != null)) { + // Only the value: the drawing gives the handle no layer to keep. + animator()?.set({[geometry.axis]: 0}, {promote: false}); + } + springing = null; + drawn = 0; + geometry = null; }, }; } diff --git a/packages/core/src/Resizable/resizeGesture.ts b/packages/core/src/Resizable/resizeGesture.ts index ce5f44819c93c..f5343896026e9 100644 --- a/packages/core/src/Resizable/resizeGesture.ts +++ b/packages/core/src/Resizable/resizeGesture.ts @@ -11,6 +11,7 @@ * events to it. No React, and no DOM work on import */ +import {VelocityTracker} from '../gestures/VelocityTracker'; import type {ResizableDirection} from './resizableModel'; /** Pixels an arrow key moves the separator; Shift moves it further. */ @@ -37,8 +38,11 @@ export interface ResizeGestureModel { start(): void; /** The gesture has moved `delta` px from where it began. */ move(delta: number): void; - /** The gesture ends as completed. */ - end(): void; + /** + * The gesture ends as completed. A pointer's lift passes its velocity along + * the resize axis, in px per second, signed as `move`'s delta. + */ + end(velocity?: number): void; /** The gesture ends without completing. */ cancel(): void; /** @@ -62,6 +66,7 @@ export interface ResizeGesturePointerEvent { readonly pointerId: number; readonly clientX: number; readonly clientY: number; + readonly timeStamp?: number; readonly currentTarget: EventTarget | null; preventDefault(): void; stopPropagation(): void; @@ -177,6 +182,8 @@ export function createResizeGesture( rtl: number; target: Element | null; } | null = null; + // The pointer's velocity at its lift, for the region's release. + const tracker = new VelocityTracker(); const rtlMultiplier = (): number => { const handle = getHandle(); @@ -229,6 +236,8 @@ export function createResizeGesture( rtl: isHorizontal ? rtlMultiplier() : 1, target, }; + tracker.reset(); + tracker.add(event.clientX, event.clientY, event.timeStamp ?? 0); onDraggingChange?.(true); model.start(); applyBodyDragStyles(isHorizontal); @@ -242,6 +251,7 @@ export function createResizeGesture( const position = config.direction === 'horizontal' ? event.clientX : event.clientY; const sign = config.isReversed ? -1 : 1; + tracker.add(event.clientX, event.clientY, event.timeStamp ?? 0); model.move((position - drag.startPos) * drag.rtl * sign); }, @@ -249,8 +259,13 @@ export function createResizeGesture( if (drag?.pointerId !== event.pointerId) { return; } + const config = getConfig(); + const {x, y} = tracker.velocity(event.timeStamp ?? 0); + const sign = config.isReversed ? -1 : 1; + const velocity = + (config.direction === 'horizontal' ? x : y) * drag.rtl * sign; clearDrag(); - model.end(); + model.end(velocity); }, // Serves pointercancel and lostpointercapture alike: an interrupted drag diff --git a/packages/core/src/Resizable/separatorAttributes.test.ts b/packages/core/src/Resizable/separatorAttributes.test.ts index 0bbae066e0480..166d6c10a5c3c 100644 --- a/packages/core/src/Resizable/separatorAttributes.test.ts +++ b/packages/core/src/Resizable/separatorAttributes.test.ts @@ -72,4 +72,16 @@ describe('separatorAttributes', () => { expect(bare['aria-valuemax']).toBeUndefined(); expect(bare.tabIndex).toBe(0); }); + + it('keeps its value to the bounds while a pull past one shows the size beyond it', () => { + const at = (size: number) => + separatorAttributes({ + direction: 'horizontal', + isDisabled: false, + value: {...value, size, minPx: 120, maxPx: 320}, + })['aria-valuenow']; + expect(at(360.4)).toBe(320); + expect(at(80)).toBe(120); + expect(at(200)).toBe(200); + }); }); diff --git a/packages/core/src/Resizable/separatorAttributes.ts b/packages/core/src/Resizable/separatorAttributes.ts index 38f67409feade..7f0184a2a3755 100644 --- a/packages/core/src/Resizable/separatorAttributes.ts +++ b/packages/core/src/Resizable/separatorAttributes.ts @@ -55,12 +55,11 @@ export function separatorAttributes( return { 'aria-orientation': state.direction === 'horizontal' ? 'vertical' : 'horizontal', + // A pull past a limit shows a size beyond it; the value stays at the bound. 'aria-valuenow': value == null ? undefined - : value.isCollapsed - ? Math.max(value.size, value.minPx) - : value.size, + : Math.min(Math.max(value.size, value.minPx), value.maxPx ?? Infinity), 'aria-valuemin': value?.minPx, 'aria-valuemax': value?.maxPx != null && Number.isFinite(value.maxPx) diff --git a/packages/core/src/Resizable/useResizable.doc.mjs b/packages/core/src/Resizable/useResizable.doc.mjs index 41a494aed4728..ff84a385d9a46 100644 --- a/packages/core/src/Resizable/useResizable.doc.mjs +++ b/packages/core/src/Resizable/useResizable.doc.mjs @@ -56,7 +56,7 @@ export const docs = { name: 'collapsible', type: 'boolean', description: - 'Whether dragging below the collapsed threshold collapses the region to zero.', + 'Whether a drag lifted below the collapsed threshold collapses the region to zero.', default: 'false', }, { @@ -179,7 +179,7 @@ export const docsDense = { containerRef: 'the element a percentage is a share of.', direction: 'which axis to resize along.', collapsible: - 'whether dragging below collapsed threshold collapses region to zero.', + 'whether a drag lifted below collapsed threshold collapses region to zero.', snaps: 'px values to snap to during drag.', autoSaveId: 'key for localStorage persistence of size + collapse state across sessions.', diff --git a/packages/core/src/Resizable/useResizable.test.ts b/packages/core/src/Resizable/useResizable.test.ts index 9e68c78844e8f..79964c249988a 100644 --- a/packages/core/src/Resizable/useResizable.test.ts +++ b/packages/core/src/Resizable/useResizable.test.ts @@ -23,7 +23,7 @@ import {flushSync} from 'react-dom'; import {renderToString} from 'react-dom/server'; import {describe, it, expect, beforeEach, afterEach, vi} from 'vitest'; import {render, renderHook, act, waitFor} from '@testing-library/react'; -import {useResizable} from './useResizable'; +import {endResizeAt, useResizable} from './useResizable'; import type { ResizableProps, ResizableRegion, @@ -168,11 +168,12 @@ describe('useResizable persistence', () => { act(() => result.current.resize(300)); act(() => result.current.props._onResizeStart()); act(() => result.current.props._onResizeMove(-270)); - expect(result.current.isCollapsed).toBe(true); - - // A continued drag below the threshold must not clobber the saved width. + // The lift decides the close; a drag below the threshold must not + // clobber the saved width either. + expect(result.current.isCollapsed).toBe(false); act(() => result.current.props._onResizeMove(-275)); act(() => result.current.props._onResizeEnd()); + expect(result.current.isCollapsed).toBe(true); expect(readStored()).toEqual({size: 300, isCollapsed: true}); act(() => result.current.expand()); @@ -318,6 +319,8 @@ describe('useResizable uncontrolled collapse', () => { act(() => result.current.props._onResizeStart()); act(() => result.current.props._onResizeMove(-240)); + expect(result.current.isCollapsed).toBe(false); + act(() => result.current.props._onResizeEnd()); expect(result.current.isCollapsed).toBe(true); expect(onCollapseChange).toHaveBeenCalledWith(true); @@ -371,6 +374,7 @@ describe('useResizable controlled collapse', () => { act(() => result.current.props._onResizeStart()); act(() => result.current.props._onResizeMove(-240)); + act(() => result.current.props._onResizeEnd()); expect(onCollapseChange).toHaveBeenCalledWith(true); expect(result.current.isCollapsed).toBe(false); @@ -426,12 +430,14 @@ describe('useResizable live collapse state', () => { for (const delta of [-240, -245, -250, -255, -260]) { act(() => gesture._onResizeMove(delta)); } + expect(onCollapseChange).not.toHaveBeenCalled(); + act(() => gesture._onResizeEnd()); expect(onCollapseChange).toHaveBeenCalledExactlyOnceWith(true); expect(result.current.isCollapsed).toBe(true); }); - it('re-expands mid-gesture when the drag crosses back above the threshold', () => { + it('stays open when the drag crosses back above the threshold before the lift', () => { const onCollapseChange = vi.fn(); const {result} = renderHook(() => useResizable({...BASE_CONFIG, collapsible: true, onCollapseChange}), @@ -441,12 +447,41 @@ describe('useResizable live collapse state', () => { const gesture = result.current.props; act(() => gesture._onResizeMove(-240)); act(() => gesture._onResizeMove(-20)); + act(() => gesture._onResizeEnd()); - expect(onCollapseChange.mock.calls).toEqual([[true], [false]]); + expect(onCollapseChange).not.toHaveBeenCalled(); expect(result.current.isCollapsed).toBe(false); expect(result.current.size).toBe(240); }); + it('closes on a lift released fast enough to come to rest past the threshold', () => { + const onCollapseChange = vi.fn(); + const {result} = renderHook(() => + useResizable({...BASE_CONFIG, collapsible: true, onCollapseChange}), + ); + + act(() => result.current.props._onResizeStart()); + act(() => result.current.props._onResizeMove(-100)); + // 1,500 px/s toward closing projects about 750 px further. + act(() => endResizeAt(result.current.props, -1500)); + + expect(onCollapseChange).toHaveBeenCalledExactlyOnceWith(true); + expect(result.current.isCollapsed).toBe(true); + }); + + it('springs back to the minimum from a slow lift above the threshold', () => { + const {result} = renderHook(() => + useResizable({...BASE_CONFIG, collapsible: true}), + ); + + act(() => result.current.props._onResizeStart()); + act(() => result.current.props._onResizeMove(-130)); + act(() => endResizeAt(result.current.props, 0)); + + expect(result.current.isCollapsed).toBe(false); + expect(result.current.size).toBe(BASE_CONFIG.minSize); + }); + it('notifies when resize() lifts the panel out of collapse', () => { const onCollapseChange = vi.fn(); const {result} = renderHook(() => diff --git a/packages/core/src/Resizable/useResizable.ts b/packages/core/src/Resizable/useResizable.ts index ff41d69de9f1c..29be142fd08b5 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, @@ -127,8 +132,8 @@ export interface UseResizableSingleOptions { defaultIsCollapsed?: boolean; /** * Controlled collapse state. When set, the caller owns collapse: - * `collapse()`, `expand()` and a drag past the collapse threshold report - * through `onCollapseChange` instead of changing state here. + * `collapse()`, `expand()` and a drag lifted past the collapse threshold + * report through `onCollapseChange` instead of changing state here. */ isCollapsed?: boolean; /** Called when size changes during drag. */ @@ -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(() => { @@ -238,6 +245,12 @@ function useSingleResizable(config: UseResizableSingleConfig): ResizableRegion { const onResizeEnd = useCallback(() => { model()?.end(); }, [model]); + const onResizeRelease = useCallback( + (velocity: number) => { + model()?.end(velocity); + }, + [model], + ); const onResizeCancel = useCallback(() => { model()?.cancel(); }, [model]); @@ -258,6 +271,10 @@ function useSingleResizable(config: UseResizableSingleConfig): ResizableRegion { _isResizableProps: true, }; collapseControls.set(props, {collapse, expand}); + releaseEnds.set(props, onResizeRelease); + // 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) { @@ -326,6 +343,13 @@ function useMultiResizable( type CollapseControls = {collapse: () => void; expand: () => void}; const collapseControls = new WeakMap(); +/** + * A drag's end with its release velocity, in px per second and signed like + * the move's delta. Kept off the exported `ResizableProps` for the same reason + * as the collapse controls; {@link endResizeAt} is internal. + */ +const releaseEnds = new WeakMap void>(); + /** * The maximum an unbounded region reports as `aria-valuemax`: a measured * extent that stands in for the missing configured maximum — the container's @@ -369,6 +393,19 @@ export function getCollapseControls( return collapseControls.get(props); } +/** + * Ends the drag `props` follows at a release `velocity`. A props object the + * hook did not return ends without one. Internal to the package. + */ +export function endResizeAt(props: ResizableProps, velocity: number): void { + const end = releaseEnds.get(props); + if (end == null) { + props._onResizeEnd(); + return; + } + end(velocity); +} + /** Removed keys must fail even when a variable bypasses excess-property checks. */ type RejectRemovedPixelBounds = Config extends unknown ? Config & {minSizePx?: never; maxSizePx?: never} @@ -520,6 +557,7 @@ interface ResizableReading { */ function useResizableController( options: CreateResizableOptions, + regionSlot?: RegionPaneSlot, ): ResizableReading { const optionsRef = useRef(options); optionsRef.current = options; @@ -527,6 +565,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 +587,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 +664,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/Resizable/useResizableForms.test.tsx b/packages/core/src/Resizable/useResizableForms.test.tsx index ad6777b18a9ec..0d949e03feafa 100644 --- a/packages/core/src/Resizable/useResizableForms.test.tsx +++ b/packages/core/src/Resizable/useResizableForms.test.tsx @@ -115,9 +115,14 @@ describe('size and isCollapsed land in the same commit as onCollapseChange (comp fireEvent.doubleClick(separator()); expect(region?.isCollapsed).toBe(false); expectInStep(); + // The lift past the threshold closes, in step with its report. fireEvent.pointerDown(grabZone(), {pointerId: 1, clientX: 300}); fireEvent.pointerMove(grabZone(), {pointerId: 1, clientX: 110}); + expect(region?.isCollapsed).toBe(false); + fireEvent.pointerUp(grabZone(), {pointerId: 1, clientX: 110}); expect(region?.isCollapsed).toBe(true); + expectInStep(); + fireEvent.pointerDown(grabZone(), {pointerId: 1, clientX: 110}); fireEvent.pointerMove(grabZone(), {pointerId: 1, clientX: 320}); fireEvent.pointerUp(grabZone(), {pointerId: 1, clientX: 320}); expect(region?.isCollapsed).toBe(false); diff --git a/packages/core/src/motion/animatedLayout.boundary.test.ts b/packages/core/src/motion/animatedLayout.boundary.test.ts index 040c11428bebc..176b90977819a 100644 --- a/packages/core/src/motion/animatedLayout.boundary.test.ts +++ b/packages/core/src/motion/animatedLayout.boundary.test.ts @@ -4,12 +4,14 @@ * @file animatedLayout.boundary.test.ts * @input The source of motion/animatedLayout.ts, the core entry points and * every core source file that imports the module - * @output The source boundary check `architecture:react-component-runtime` - * INV12 asks for: the module animates no layout property, its walk reaches + * @output The source boundary check that + * `architecture:react-component-runtime` INV12 asks for. The module + * animates no layout property. Its walk reaches * 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 + * side, and firstElementChild under `display: contents`. It stops at the + * band around the visible part. The reporting functions are not public. + * 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 */ @@ -162,6 +164,7 @@ describe('architecture:react-component-runtime INV12 source boundary', () => { 'Collapsible/Collapsible.tsx', 'List/List.tsx', 'List/listLayoutUpdates.ts', + 'Resizable/resizablePane.ts', 'SideNav/railMotion.ts', 'Table/plugins/rowExpansion/useTableRowExpansion.tsx', 'TreeList/TreeList.tsx', diff --git a/packages/core/src/motion/animatedLayout.edge.test.ts b/packages/core/src/motion/animatedLayout.edge.test.ts index 4228f791da38f..0b3ac3c625ef7 100644 --- a/packages/core/src/motion/animatedLayout.edge.test.ts +++ b/packages/core/src/motion/animatedLayout.edge.test.ts @@ -6,16 +6,17 @@ * Element.animate (its KeyframeEffect's `setKeyframes` and `updateTiming` * included, and a current time read off a stubbed document timeline) and * stubbed layout rectangles (jsdom lays nothing out) - * @output Checks `spec:AST-105` FR9's edge reveal: the one-state reveal and - * conceal from each edge, left to right and right to left; a reversal that - * starts from what shows; the moving edge riding the glide of the content - * after it, through a reversal too; and the two-state form, which holds the - * old state as one copy while the edge rides a gliding element, the element - * raised above the copy while it conceals, a reversal starting from the - * 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 + * @output Checks `spec:AST-105` FR9's edge reveal. The one-state reveal and + * conceal play from each edge, left to right and right to left. A reversal + * starts from what shows. The moving edge rides the glide of the content + * after it, through a reversal too. The two-state form holds the old state + * as one copy while the edge rides a gliding element. That element stays + * above the copy while it conceals, a reversal starts from the extent it + * shows, and Reduce Motion goes by pointer. A resized element's reveal (a + * resize controller's pane) uses no copy. While it conceals, it holds the + * extent it was laid out at, and its edge's margin frees the difference. + * Its padding, overflow and scroll offset stay as laid out. It is clipped + * to its new extent and rides 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'); diff --git a/packages/core/src/motion/animatedLayout.ts b/packages/core/src/motion/animatedLayout.ts index 56198c48a4065..0b26227736304 100644 --- a/packages/core/src/motion/animatedLayout.ts +++ b/packages/core/src/motion/animatedLayout.ts @@ -91,6 +91,12 @@ import { wrapText, } from './transientCopy'; import {readThemeTiming, readThemeTokenText} from './themeTiming'; +import { + settlingDuration, + solveSpring, + toSpring, + type SpringDescription, +} from './spring'; import {themeReducesMotion} from '../privateAttributes/reducedMotion.stylex'; import {reducedMotionCrossFade} from './uikit/timing'; import { @@ -122,7 +128,7 @@ type LayoutMotion = | {readonly kind: 'instant'}; /** The move curve as remaining distance (1 → 0) over its duration. */ -interface GlideProgress { +export interface GlideProgress { readonly durationMs: number; /** The move curve's CSS text, as read where the area is. */ readonly easing: string; @@ -160,6 +166,63 @@ function glideProgress(area: Element): GlideProgress | null { }; } +// A glide a gesture's release asks for, for the changes in one area. +const releaseGlides = new WeakMap(); + +/** + * Times the changes `area` and what it holds play in the next frame by + * `progress`, not the theme's move curve. A release settles on its spring. + * Reduce Motion still wins. + */ +export function glideNextChange(area: Element, progress: GlideProgress): void { + releaseGlides.set(area, progress); + const view = area.ownerDocument.defaultView; + view?.requestAnimationFrame(() => { + view.requestAnimationFrame(() => releaseGlides.delete(area)); + }); +} + +/** + * `spring` from rest toward the end, released at `velocity` (fractions of + * the move per second, toward the end), as a glide's progress. + */ +export function springGlide( + spring: SpringDescription, + velocity: number, +): GlideProgress { + const physics = toSpring(spring); + const curve = solveSpring(physics, {from: 0, to: 1, velocity}); + const durationMs = + settlingDuration(physics, {initialVelocity: velocity}) * 1000; + const remaining = (t: number) => + t >= durationMs ? 0 : 1 - curve.value(t / 1000); + return {durationMs, easing: linearEasing(durationMs, remaining), remaining}; +} + +/** A move's remaining fraction over time as CSS `linear()` easing text. */ +function linearEasing( + durationMs: number, + remaining: (t: number) => number, +): string { + const count = sampleCount(durationMs); + const stops: string[] = []; + for (let i = 0; i < count; i++) { + const done = 1 - remaining((i / (count - 1)) * durationMs); + stops.push(String(Math.round(done * 10000) / 10000)); + } + return `linear(${stops.join(', ')})`; +} + +function releaseGlideFor(area: Element): GlideProgress | undefined { + for (let at: Element | null = area; at != null; at = at.parentElement) { + const progress = releaseGlides.get(at); + if (progress != null) { + return progress; + } + } + return undefined; +} + /** The motion a change in `area` plays now. */ function layoutMotion(area: Element): LayoutMotion { if ( @@ -179,7 +242,7 @@ function layoutMotion(area: Element): LayoutMotion { easing: `cubic-bezier(${x1}, ${y1}, ${x2}, ${y2})`, }; } - const progress = glideProgress(area); + const progress = releaseGlideFor(area) ?? glideProgress(area); return progress == null ? {kind: 'instant'} : {kind: 'glide', progress}; } @@ -3344,12 +3407,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, @@ -3357,10 +3464,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 => [ @@ -3391,6 +3509,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 @@ -3416,6 +3539,7 @@ function holdExtent( return null; } } + restoreScroll(element, inside); return restore; } @@ -3429,7 +3553,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 @@ -3446,7 +3572,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; @@ -3462,12 +3594,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( @@ -3475,6 +3610,7 @@ export function reportLayoutEdge( ), box: running.box, rect, + inside, }; running?.release(); } @@ -3488,6 +3624,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; @@ -3528,7 +3666,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; diff --git a/scripts/motion-roles-table.mjs b/scripts/motion-roles-table.mjs index cba6cf219ddaa..9fce5fbddf5f5 100644 --- a/scripts/motion-roles-table.mjs +++ b/scripts/motion-roles-table.mjs @@ -109,7 +109,7 @@ export function renderMotionRoleTable(roles, {fr4, records}) { '', '# Motion roles', '', - "The owner's goal: \"The main goals are consistent timings and well-coordinated pieces.\" The rules serve it: springs for a component whose main interaction is a gesture, including the moves a tap starts inside it; curve tokens for a component whose main interaction is a tap, tap-to-expand included; the nearest-token mapping for a measured duration; one coordinated scale for springs and curves through `tempo`, each spring paired with a timing tier (`spec:AST-094` FR45); and no per-component exceptions and no platform-native option. The owner's guidance: \"The guidance im thinking is: timing should be coordinated between spring and motion curve tokens, then use spring for gestures where velocity matters, use motion curves for taps presses and other non gesture motion.\" A component whose main interaction is a gesture plays springs for its motion, including moves a tap starts inside it (a tap on a sheet detent or a close button settles on the same spring), so its pieces stay coordinated. A component whose main interaction is a tap plays the theme's curve tokens (duration and easing, scaled by `tempo`), never a spring, since a tap carries no velocity; no character or option plays a platform-measured curve for it (owner decision, 2026-11-04). `spec:AST-094` FR45 lists which components each covers. Every motion role a registered table lists (`spec:AST-094` FR4, DEC-5): its kind, its default as the table measures it, and the record that names it. A role FR4 lists is named by FR4; any other role is one a component's own record measures, named by the current component records that carry its id. In a component whose main interaction is a tap, a role plays only for motion a gesture drives; in a gesture component, its roles also settle the moves a tap, a key or the program starts (`spec:AST-094` FR45). In a component whose main interaction is a tap, motion that a tap, a click, a hold, a key or the program starts plays the theme's duration and easing tokens, and an exit it starts plays the theme's exit duration and easing (`spec:AST-094` FR7), and a role measured for that motion stays as the native reference. Two approved motions keep their springs until their own records change: the animator's `animate` (`spec:AST-107` FR3) and `useDragToSnapPoints`'s `settleTo` (`spec:AST-107`). AST-105's glide, swap and edge reveal are tap-started layout motion and play the theme's curve tokens (`spec:AST-105` FR4). Reduce Motion substitutions and continuous busy loops (such as ProgressBar's indeterminate pulse, `component:ProgressBar` FR6) keep their named roles; they are neither gesture- nor interaction-started motion. Measured input timings, such as gesture thresholds, hold durations and flick speeds, are not motion roles.", + "The owner's goal: \"The main goals are consistent timings and well-coordinated pieces.\" The rules serve it: springs for a component whose main interaction is a gesture, including the moves a tap starts inside it; curve tokens for a component whose main interaction is a tap, tap-to-expand included; the nearest-token mapping for a measured duration; one coordinated scale for springs and curves through `tempo`, each spring paired with a timing tier (`spec:AST-094` FR45); and no per-component exceptions and no platform-native option. The owner's guidance: \"The guidance im thinking is: timing should be coordinated between spring and motion curve tokens, then use spring for gestures where velocity matters, use motion curves for taps presses and other non gesture motion.\" A component whose main interaction is a gesture plays springs for its motion, including moves a tap starts inside it (a tap on a sheet detent or a close button settles on the same spring), so its pieces stay coordinated. A component whose main interaction is a tap plays the theme's curve tokens (duration and easing, scaled by `tempo`), never a spring, since a tap carries no velocity; no character or option plays a platform-measured curve for it (owner decision, 2026-11-04). `spec:AST-094` FR45 lists which components each covers. Every motion role a registered table lists (`spec:AST-094` FR4, DEC-5): its kind, its default as the table measures it, and the record that names it. A role FR4 lists is named by FR4; any other role is one a component's own record measures, named by the current component records that carry its id. In a component whose main interaction is a tap, a role plays only for motion a gesture drives; in a gesture component, its roles also settle the moves a tap, a key or the program starts (`spec:AST-094` FR45). In a component whose main interaction is a tap, motion that a tap, a click, a hold, a key or the program starts plays the theme's duration and easing tokens, and an exit it starts plays the theme's exit duration and easing (`spec:AST-094` FR7), and a role measured for that motion stays as the native reference. Two approved motions keep their springs until their own records change: the animator's `animate` (`spec:AST-107` FR3) and `useDragToSnapPoints`'s `settleTo` (`spec:AST-107`). AST-105's glide, swap and edge reveal are tap-started layout motion and play the theme's curve tokens, except a gesture's release, which settles on its spring (`spec:AST-105` FR4). Reduce Motion substitutions and continuous busy loops (such as ProgressBar's indeterminate pulse, `component:ProgressBar` FR6) keep their named roles; they are neither gesture- nor interaction-started motion. Measured input timings, such as gesture thresholds, hold durations and flick speeds, are not motion roles.", '', '| Role | Kind | Default | Named by |', '| --- | --- | --- | --- |', diff --git a/scripts/touch-support-bundle.test.mjs b/scripts/touch-support-bundle.test.mjs index 04893260aa831..77996810adaf0 100644 --- a/scripts/touch-support-bundle.test.mjs +++ b/scripts/touch-support-bundle.test.mjs @@ -209,6 +209,9 @@ const GESTURE_USERS = new Set([ 'Tokenizer', // Slider's thumb. 'Slider', + // Resizable: its drag tracks the release velocity that the lift's close and + // the handle's spring-back start from. + 'Resizable', // Switch: its press tracking and its drag, for every pointer. 'Switch', // SegmentedControl: its press tracking and its drag, for every pointer.