Repository navigation
feat(DropdownMenu): a finger's open grows the menu out of its trigger #8080
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
3dd581e
3cfb3b1
273da1e
f1149cd
131ca14
f19df27
e3a43d0
c99210a
3272828
df642e0
8c8f726
3fdca8f
d4903e2
1b794af
5dd6a6a
3bfa524
ad7f8fb
99d352d
33a3ee8
2bf8f8a
822013a
2651e17
6354fa3
dcfa346
75391ad
6b6310c
e8face7
d08f0d3
eb2c859
de86bc9
7419b9d
927c081
61b57b9
badea5d
1f576a5
75b3111
b3d3565
86871f9
36122dd
b08ab80
3f35abd
a71e804
bf9675e
2e37b3a
78442d4
09bb9c2
b24c23c
9d40852
6c17632
542e150
bdb2003
3b7e253
92dd62a
a831c2c
2d41fe6
a88dfbb
cab4595
3b51d59
14f3d32
c13c351
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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`. | ||
|
|
||
| @vjeux | ||
| 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 |
| 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 |
| 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. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
|
|
||
| @vjeux | ||
| 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. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
|
|
||
| @vjeux | ||
| 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> | ||
| ); | ||
| } |
| 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> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.