Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
3dd581e
feat(core): one progress module for the motion foundation
vjeux Nov 3, 2026
3cfb3b1
refactor(animatedLayout): take the settle spring's progress from prog…
cixzhang Nov 3, 2026
273da1e
feat(core): the rect morph's paths, the rect a grow-out presentation …
vjeux Nov 3, 2026
f1149cd
Merge remote-tracking branch 'origin/main' into motion/morph-path
cixzhang Nov 3, 2026
131ca14
feat(core): one rect morph for touch presentations that grow out of a…
vjeux Nov 3, 2026
f19df27
fix(rectMorph): a handed-in animator stays on the element through a c…
cixzhang Nov 3, 2026
e3a43d0
fix(motion): one timed curve keeps a retargeted velocity, for the pag…
cixzhang Nov 4, 2026
c99210a
fix(rectMorph): a timed motion from rest plays its easing as given
cixzhang Nov 4, 2026
3272828
docs(rectMorph): the timing follows the component's motion record
cixzhang Nov 5, 2026
df642e0
Merge main into motion/morph-path
vjeux Nov 8, 2026
8c8f726
Merge motion/morph-path into motion/rect-morph
vjeux Nov 8, 2026
3fdca8f
fix(rectMorph): AST-094 links the morph; the proof imports a safe test
vjeux Nov 8, 2026
d4903e2
test(SnapPaging): the pager's bundle lists the progress module
vjeux Nov 8, 2026
1b794af
test(motion): the popover's proofs read Popover's live geometry
vjeux Nov 8, 2026
5dd6a6a
Merge motion/morph-path into motion/rect-morph
vjeux Nov 8, 2026
3bfa524
refactor(motion): one eased progress, with a retargeted velocity
vjeux Nov 8, 2026
ad7f8fb
test(motion): the popover's turn-around reads Popover's live curves
vjeux Nov 8, 2026
99d352d
Merge motion/morph-path into motion/rect-morph
vjeux Nov 8, 2026
33a3ee8
docs(changeset): the rect morph's note names the eased-progress move
vjeux Nov 8, 2026
2bf8f8a
Merge main into motion/rect-morph
vjeux Nov 9, 2026
822013a
test(motion): the popover's turn-around compares opacity as drawn
vjeux Nov 9, 2026
2651e17
Merge motion/morph-path into motion/rect-morph
vjeux Nov 9, 2026
6354fa3
fix(motion): a popover's morph never reads an opacity below 0
vjeux Nov 10, 2026
dcfa346
Merge motion/morph-path into motion/rect-morph
vjeux Nov 10, 2026
75391ad
feat(DropdownMenu): a finger or a pen opens the menu as it presses
vjeux Nov 10, 2026
6b6310c
test(motion): the morph proofs drop the menu's old straight line
vjeux Nov 10, 2026
e8face7
Merge main
vjeux Nov 10, 2026
d08f0d3
Merge main
vjeux Nov 10, 2026
eb2c859
docs(DropdownMenu): open-on-press notes name the anchored menu
vjeux Nov 10, 2026
de86bc9
sandbox: a page to try a menu's press under a finger
vjeux Nov 10, 2026
7419b9d
Merge main
vjeux Nov 10, 2026
927c081
Merge the open-on-press change into the rect morph stack
vjeux Nov 10, 2026
61b57b9
feat(DropdownMenu): a finger's open grows the menu out of its trigger
vjeux Nov 10, 2026
badea5d
fix(DropdownMenu): under Reduce Motion a finger's open only fades in
vjeux Nov 10, 2026
1f576a5
test(DropdownMenu): touch specs wait out a finger's grow
vjeux Nov 10, 2026
75b3111
refactor(core): one Reduce Motion predicate for the animator and menu
vjeux Nov 10, 2026
b3d3565
fix(DropdownMenu): every copy of Core shares the grow's one load
vjeux Nov 10, 2026
86871f9
fix(DropdownMenu): taps stay off the page until the menu shows
vjeux Nov 11, 2026
36122dd
docs(AST-094): FR31 grows once the grow's code is loaded
vjeux Nov 11, 2026
b08ab80
docs(AST-094): FR31's faded open closes with no shrink
vjeux Nov 11, 2026
3f35abd
fix(DropdownMenu): the grow's code loads through the shared loader
vjeux Nov 11, 2026
a71e804
Merge main
vjeux Nov 11, 2026
bf9675e
Merge the open-on-press change into the rect morph stack
vjeux Nov 11, 2026
2e37b3a
Merge main and the open-on-press change
vjeux Nov 11, 2026
78442d4
Merge commit 'ebb9ddecc0219bc6eaf3caf40b6e4e45f81dc0e5' into motion/r…
cixzhang Nov 11, 2026
09bb9c2
fix(motion): existing callers keep easedProgress's values; the pager …
cixzhang Nov 11, 2026
b24c23c
test(paging): a retarget at the first instant goes on at the starting…
cixzhang Nov 11, 2026
9d40852
test(paging): a 0 ms move is on its target at once, at rest
cixzhang Nov 11, 2026
6c17632
test(motion): compare the callers' values with copies of the earlier …
cixzhang Nov 11, 2026
542e150
Merge the open-on-press change into the rect morph stack
vjeux Nov 11, 2026
bdb2003
Merge the rect morph's new head
vjeux Nov 11, 2026
3b7e253
refactor(core): one useMenuPress for every menu
vjeux Nov 11, 2026
92dd62a
Merge the open-on-press change into the rect morph stack
vjeux Nov 11, 2026
a831c2c
Merge the open-on-press change's rework
vjeux Nov 11, 2026
2d41fe6
refactor(DropdownMenu): grow with the page, on Popover's surface motion
vjeux Nov 11, 2026
a88dfbb
docs(Popover): name the surface motion options DropdownMenu passes
vjeux Nov 11, 2026
cab4595
Merge main
vjeux Nov 11, 2026
3b51d59
Merge the open-on-press change into the rect morph stack
vjeux Nov 11, 2026
14f3d32
Merge the open-on-press change's main merge
vjeux Nov 11, 2026
c13c351
test(DropdownMenu): read the row under a held finger once the grow rests
vjeux Nov 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/menu-press-public-default.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@astryxdesign/core': patch
---

[feature] `useMenuPress` with no `longPressDelayMs` now follows DropdownMenu's finger rules on every device (`spec:AST-094` FR25, FR29, FR43): a finger or a pen opens the menu as it presses (inside a scroll view, once the 150 ms content-touch delay has passed), and the finger that opened it picks a row only after moving 10 px. To keep the previous behavior, a 500 ms hold and a pick at the opening finger's lift, pass `longPressDelayMs: 500`.
Comment thread
astracat-bot[bot] marked this conversation as resolved.
Resolved

@vjeux
7 changes: 7 additions & 0 deletions .changeset/menu-press-uimenu.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@astryxdesign/core': patch
---

[feature] Under a finger or a pen, on every device, the trigger of an anchored DropdownMenu or MoreMenu now opens its menu as it is pressed, instead of after a 500 ms hold (`spec:AST-094` FR29, FR43). Inside a scroll view it opens once the 150 ms content-touch delay has passed with no scroll, so a scroll that starts on it still scrolls. To keep the previous behavior, a tap that opens at the lift and a 500 ms hold, pass `presentation="popover"`. A menu shown as a bottom sheet, and a mouse, are unchanged.

@vjeux
7 changes: 7 additions & 0 deletions .changeset/menu-touch-grow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@astryxdesign/core': patch
---

[feature] Under a finger or a pen, a DropdownMenu or MoreMenu now grows out of its trigger as it opens and shrinks back into it as it closes, on a Popover's path and timing (`spec:AST-094` FR31). A tap's open grows as a hold's does. A mouse, the keyboard, Reduce Motion and `presentation="popover"` keep the layer's own entrance.

@vjeux
7 changes: 7 additions & 0 deletions .changeset/motion-morph-path.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@astryxdesign/core': patch
---

[chore] Internal: the rect morph's paths, the geometry touch presentations share when they grow out of a source rect and shrink back into it. `lerpPath` (a straight line between two rects) and `edgeGrowPath` (UIKit's popover, growing out of a point on its edge) name the rect an element shows at each progress, and say how an interrupted motion continues in their own law; `morphCurves` turns a path into compositor curves (`translate`, `scale` and a companion `opacity`). Not exported from the package entry point; no component uses them yet.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Miss: "no component uses them yet" is no longer true in this range: DropdownMenu's grow plays edgeGrowPath (menuSurfaceMotion.ts:85-91). Could this clause go, or #9764 land first?


@vjeux
7 changes: 7 additions & 0 deletions .changeset/motion-rect-morph.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@astryxdesign/core': patch
---

[chore] Internal: the rect morph, one way for a touch presentation to grow out of a source rect and shrink back into it. `createRectMorph` presents an element along a path (`lerpPath`, `edgeGrowPath`) and dismisses it back on a copy or on itself, each continuing the other from the rect on screen. Each play takes the timing the component's motion record names (AST-094 FR45): a duration and easing, or a spring, starting from the velocity it is given. Reduced Motion is left to the animator. `easedProgress` moves to the progress helpers with the same values. `timedCurve` joins them: a timed motion that keeps a retargeted velocity, for the pager and the rect morph. Not exported from the package entry point; no component uses it yet.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Miss: this line was reworded at this head but still ends "no component uses it yet", while DropdownMenu's grow plays on the rect morph (menuSurfaceMotion.ts:29-35, createRectMorph), and these notes ship together. Could the clause go, or #9764 and #9769 land first?


@vjeux
2 changes: 2 additions & 0 deletions apps/sandbox/scripts/next-export-routes.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@
"/pages/markdown-perf/",
"/pages/media-mode/",
"/pages/mega-menu/",
"/pages/menu-grow-cases/",
"/pages/menu-press-cases/",
"/pages/minute-wheel-loop/",
"/pages/mobile-prototypes/",
"/pages/mobile-spacing/",
Expand Down
204 changes: 204 additions & 0 deletions apps/sandbox/src/app/(raw)/pages/menu-grow-cases/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
// Copyright (c) Meta Platforms, Inc. and affiliates.

'use client';

/**
* @file page.tsx
* @input None
* @output A bare page of large DropdownMenus and a Popover, spread down a
* page that scrolls with the document
* @position A raw page, with no sandbox chrome, for checking on a phone how
* menus grow out of their trigger and shrink back. It has a tall menu that
* scrolls, a wide one and a filter menu with many rows. It also has nested
* sub-menus, menus at each edge, one that flips above, and a Popover.
*/

import {Button} from '@astryxdesign/core/Button';
import {
DropdownMenu,
type DropdownMenuOption,
} from '@astryxdesign/core/DropdownMenu';
import {HStack, VStack} from '@astryxdesign/core/Layout';
import {Popover} from '@astryxdesign/core/Popover';
import {Heading, Text} from '@astryxdesign/core/Text';

const SHORT: DropdownMenuOption[] = [
{label: 'Edit'},
{label: 'Duplicate'},
{label: 'Share'},
{label: 'Delete', variant: 'destructive'},
];

const TALL: DropdownMenuOption[] = Array.from({length: 40}, (_, index) => ({
label: `Project ${index + 1}`,
}));

const WIDE: DropdownMenuOption[] = [
{
label: 'Export the whole workspace as a spreadsheet',
description: 'Every table, with its filters and hidden columns',
},
{
label: 'Copy a link that opens this exact view',
description: 'Sorting, grouping and the scroll position travel with it',
},
{
label: 'Send a weekly summary to the team channel',
description: 'Every Monday morning, in the channel time zone',
},
];

function section(title: string, labels: string[]): DropdownMenuOption {
return {type: 'section', title, items: labels.map(label => ({label}))};
}

const FILTERS: DropdownMenuOption[] = [
section('Status', ['Open', 'In progress', 'In review', 'Blocked', 'Done']),
{type: 'divider'},
section('Priority', ['Urgent', 'High', 'Medium', 'Low']),
{type: 'divider'},
section('Owner', [
'Anyone',
'Me',
'My team',
'Unassigned',
'Recently active',
'Former members',
]),
{type: 'divider'},
section('Labels', [
'Bug',
'Feature',
'Design',
'Docs',
'Performance',
'Accessibility',
'Security',
'Mobile',
]),
];

const NESTED: DropdownMenuOption[] = [
{label: 'New file'},
{
label: 'Move to',
items: [
{label: 'Inbox'},
{
label: 'Projects',
items: [
{label: 'Website'},
{label: 'Mobile app'},
{
label: 'Archive',
items: [{label: '2024'}, {label: '2025'}, {label: '2026'}],
},
],
},
{label: 'Shared with me'},
],
},
{
label: 'Share',
items: [{label: 'Copy link'}, {label: 'Email'}, {label: 'Message'}],
},
{label: 'Delete', variant: 'destructive'},
];

const FILLER = Array.from({length: 12}, (_, index) => `Row ${index + 1}`);

function Filler() {
return (
<VStack gap={2}>
{FILLER.map(row => (
<Text key={row} color="secondary">
{row}
</Text>
))}
</VStack>
);
}

function Case({title, children}: {title: string; children: React.ReactNode}) {
return (
<VStack gap={2}>
<Text weight="semibold">{title}</Text>
{children}
</VStack>
);
}

export default function MenuGrowCasesPage() {
return (
<VStack gap={6} padding={4}>
<HStack justify="between">
<DropdownMenu button={{label: 'Top left'}} items={SHORT} />
<DropdownMenu
button={{label: 'Top right'}}
items={SHORT}
alignment="end"
/>
</HStack>
<VStack gap={1}>
<Heading level={1}>Menu grow cases</Heading>
<Text color="secondary">
Tap or hold each trigger: the menu grows out of it and shrinks back
into it. The Popover below grows the same way.
</Text>
</VStack>
<Case title="A tall menu that scrolls (40 rows)">
<DropdownMenu button={{label: 'Projects'}} items={TALL} />
</Case>
<Case title="A wide menu">
<DropdownMenu button={{label: 'Export'}} items={WIDE} menuWidth={360} />
</Case>
<Case title="A filter menu with many rows in sections">
<DropdownMenu
button={{label: 'Filters'}}
items={FILTERS}
menuMaxHeight={520}
/>
</Case>
<Filler />
<Case title="Nested sub-menus">
<DropdownMenu button={{label: 'File'}} items={NESTED} />
</Case>
<Case title="A Popover, for comparison">
<Popover
label="Filter"
width={280}
content={
<VStack gap={2} padding={3}>
<Text weight="semibold">Filter</Text>
{['Open', 'In progress', 'In review', 'Done'].map(label => (
<Text key={label}>{label}</Text>
))}
</VStack>
}>
<Button label="Popover" />
</Popover>
</Case>
<Case title="At the side edges">
<HStack justify="between">
<DropdownMenu button={{label: 'Left edge'}} items={FILTERS} />
<DropdownMenu
button={{label: 'Right edge'}}
items={WIDE}
alignment="end"
/>
</HStack>
</Case>
<Filler />
<Case title="At the bottom: scroll to the end, and it flips above">
<HStack justify="between">
<DropdownMenu button={{label: 'Bottom left'}} items={FILTERS} />
<DropdownMenu
button={{label: 'Bottom right'}}
items={NESTED}
alignment="end"
/>
</HStack>
</Case>
</VStack>
);
}
83 changes: 83 additions & 0 deletions apps/sandbox/src/app/(raw)/pages/menu-press-cases/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// Copyright (c) Meta Platforms, Inc. and affiliates.

'use client';

/**
* @file page.tsx
* @input None
* @output A bare page with a DropdownMenu in a fixed top bar and one in the
* content of a page that scrolls with the document
* @position A raw page, with no sandbox chrome, for checking on a phone how a
* menu opens under a finger. The menu in the fixed bar opens as the finger
* presses. The menu in the content waits for the content-touch delay, so a
* flick that starts on it scrolls the page.
*/

import {useState} from 'react';
import * as stylex from '@stylexjs/stylex';
import {
DropdownMenu,
type DropdownMenuOption,
} from '@astryxdesign/core/DropdownMenu';
import {HStack, VStack} from '@astryxdesign/core/Layout';
import {Heading, Text} from '@astryxdesign/core/Text';
import {colorVars, spacingVars} from '@astryxdesign/core/theme/tokens.stylex';

const styles = stylex.create({
// A fixed bar: a trigger under a fixed ancestor is not in a scroll view.
bar: {
position: 'fixed',
insetBlockStart: 0,
insetInlineStart: 0,
insetInlineEnd: 0,
zIndex: 1,
paddingBlock: spacingVars['--spacing-2'],
paddingInline: spacingVars['--spacing-4'],
backgroundColor: colorVars['--color-background-body'],
},
content: {
paddingBlockStart: spacingVars['--spacing-12'],
},
});

const ROW_COUNT = 40;

export default function MenuPressCasesPage() {
const [ran, setRan] = useState<string[]>([]);
const run = (label: string) => () => setRan(previous => [...previous, label]);
const items = (prefix: string): DropdownMenuOption[] =>
['Edit', 'Duplicate', 'Share'].map(label => ({
label,
onClick: run(`${prefix}: ${label}`),
}));
const last = ran.at(-1);
const lastCount = ran.filter(entry => entry === last).length;

return (
<VStack gap={6} padding={4} xstyle={styles.content}>
<HStack justify="between" align="center" xstyle={styles.bar}>
<DropdownMenu button={{label: 'Actions'}} items={items('Actions')} />
<Text color="secondary" aria-live="polite">
{last == null ? 'Nothing ran yet' : `Ran ${last} (${lastCount}×)`}
</Text>
</HStack>
<VStack gap={1}>
<Heading level={1}>Menu press cases</Heading>
<Text color="secondary">
Under a finger, Actions in the fixed bar opens as the finger touches
it. Press it, slide to Duplicate and lift: Duplicate runs once.
</Text>
<Text color="secondary">
More, below, sits in the page, which scrolls. It opens after a short
beat, so a flick that starts on it scrolls the page instead.
</Text>
</VStack>
<DropdownMenu button={{label: 'More'}} items={items('More')} />
{Array.from({length: ROW_COUNT}, (_, index) => (
<Text key={index}>
Row {index + 1}: text that makes the page taller than the screen.
</Text>
))}
</VStack>
);
}
12 changes: 12 additions & 0 deletions apps/sandbox/src/app/sandboxPages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,12 +105,24 @@ export const categories: SandboxCategory[] = [
description:
'An alert over a toast and a dialog closed after a save, on a page that scrolls with the document, to check on a phone',
},
{
name: 'Menu Press Cases',
href: '/pages/menu-press-cases/',
description:
'A DropdownMenu in a fixed top bar that opens as a finger presses it, and one in the content, on a page that scrolls with the document',
},
{
name: 'BottomSheet Pull',
href: '/pages/bottom-sheet-pull/',
description:
'A BottomSheet of rows over a page that scrolls with the document, to check a slow pull and a tap on a phone',
},
{
name: 'Menu Grow Cases',
href: '/pages/menu-grow-cases/',
description:
'Large DropdownMenus and a Popover that grow out of their trigger: tall, wide, many rows, nested, at the edges and flipped, on a page that scrolls with the document',
},
{
name: 'List Reorder Cases',
href: '/pages/list-reorder-cases/',
Expand Down
4 changes: 3 additions & 1 deletion docs/architecture/react-component-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,9 @@ those contracts.
change and moves shifted content with a compositor-only translate played
through the shared rectangle morph (`architecture:layer-runtime` INV15;
`spec:AST-094` FR5) once Core has that morph; until then, the module plays
its own compositor translate. It never animates height. Its walk visits only
its own compositor translate. (Implementation gap: Core has the shared
rectangle morph, but the size-change glide still plays its own compositor
translate.) It never animates height. Its walk visits only
Comment on lines +229 to +231

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking: this parenthetical is build status inside INV12's requirement. Could it move to the record's gaps, naming INV12 and the observable mismatch, or go?

the changed element's ancestors up to the area and the element siblings on
either side at each of their levels, through `parentElement`,
`nextElementSibling` and `previousElementSibling`, descending only into an
Expand Down
1 change: 1 addition & 0 deletions docs/specs/AST-094-touch-presentations/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ verified_by:
"packages/core/src/Layer/holdEntranceInput.ts",
"packages/core/src/Layer/inputHold.ts",
"packages/core/src/hooks/usePager.ts",
"packages/core/src/motion/rectMorph.ts",
"packages/core/src/motion/uikit/segmented.ts",
"packages/core/src/motion/uikit/slider.ts"
]
Expand Down
Loading