Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,4 @@ node_modules/
.pnp.loader.mjs
.yarnrc.yml
*storybook.log
.env
70 changes: 70 additions & 0 deletions evidence/popper.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Popper Animation Evidence
Date: 2026-02-22

## Sources
### Primary references
- User-provided design reference video:
- https://firebasestorage.googleapis.com/v0/b/design-spec/o/projects%2Fgoogle-material-3%2Fimages%2Fmhlk1jwz-GM3_Menus_Guidelines%2001_IA_v01.mp4?alt=media&token=2e7dfcc9-2447-4808-8234-83c313d82df8
- Material 3 motion pages:
- https://m3.material.io/styles/motion/overview
- https://m3.material.io/foundations/motion/applying-easing-and-duration
- https://m3.material.io/styles/motion/easing-and-duration/tokens-specs

### Token/easing references used as practical source of truth
- Flutter generated motion tokens:
- https://flutter.googlesource.com/mirrors/packages/+/refs/tags/google_maps_flutter-v2.7.0/packages/flutter/lib/src/material/motion.dart
- Flutter API docs for durations and easing:
- https://api.flutter.dev/flutter/material/Durations-class.html
- https://api.flutter.dev/flutter/material/Easing/standard-constant.html
- https://api.flutter.dev/flutter/material/Easing/standardAccelerate-constant.html
- https://api.flutter.dev/flutter/material/Easing/standardDecelerate-constant.html
- https://api.flutter.dev/flutter/material/Easing/emphasizedAccelerate-constant.html
- https://api.flutter.dev/flutter/material/Easing/emphasizedDecelerate-constant.html

### Internal empirical source
- Implementation and local validation log in `draft.txt`:
- wrapper split (`.m3-popper-positioner` + `.m3-popper`)
- updated E2E assertions
- local check commands and outcomes

## Extracted Facts
### Direct facts
1. Positioning and animation transforms conflict if both are applied to the same element.
2. Splitting responsibilities into two elements is technically viable:
- outer element handles geometry (`absolute/top/left/transform` from floating-ui),
- inner element handles visual animation (`transform/opacity/visibility`).
3. Show ordering matters: first position (`await adjust`), then reveal content.
4. Animation direction must use the effective side after flip (actual placement), not requested placement.
5. Headless access to `m3.material.io` pages may be limited (JS-required shell), so token tables were taken from official generated/tokenized sources.
6. Local verification was reported as green after stabilization:
- `tsc` for foundation/react/vue,
- `eslint`,
- unit and e2e checks for popper,
- combined coverage run (`80.11%`).

### Motion token facts (from token references)
1. Duration token scale:
- short: 50/100/150/200 ms,
- medium: 250/300/350/400 ms,
- long: 450/500/550/600 ms,
- extra long: 700/800/900/1000 ms.
2. Easing token curves:
- standard: `cubic-bezier(0.2, 0.0, 0.0, 1.0)`,
- standardAccelerate: `cubic-bezier(0.3, 0.0, 1.0, 1.0)`,
- standardDecelerate: `cubic-bezier(0.0, 0.0, 0.0, 1.0)`,
- emphasizedAccelerate: `cubic-bezier(0.3, 0.0, 0.8, 0.15)`,
- emphasizedDecelerate: `cubic-bezier(0.05, 0.7, 0.1, 1.0)`,
- linear: `cubic-bezier(0.0, 0.0, 1.0, 1.0)`.

### Inferences used for implementation tuning
1. Enter animation should prefer decelerate-family easing, exit should prefer accelerate-family easing.
2. Perceived "menu unfolding from anchor point" depends on:
- side-aware `transform-origin`,
- axis-dominant scale (uncollapse) with small translation offset.
3. Candidate presets for iterative tuning:
- `short3/short1` or `short4/short2`,
- decelerate for enter + accelerate for exit.

## Notes for future work
- Keep parity of popper behavior checks in React and Vue E2E.
- Freeze chosen duration/easing pair in tests via computed-style assertions to prevent regressions.
Original file line number Diff line number Diff line change
Expand Up @@ -87,4 +87,4 @@
flex: 1 0 0;
color: var(--m3-sys-on-surface);
}
}
}
71 changes: 63 additions & 8 deletions m3-foundation/assets/stylesheets/components/popper/index.scss
Original file line number Diff line number Diff line change
@@ -1,22 +1,77 @@
@use "../../basics/motion" as m3-motion;

.m3-popper-positioner {
position: absolute;
top: 0;
left: 0;
}

.m3-popper {
--m3-popper-enter-duration: #{m3-motion.duration('short4')};
--m3-popper-exit-duration: #{m3-motion.duration('short2')};
--m3-popper-opacity-enter-duration: #{m3-motion.duration('short2')};
--m3-popper-opacity-exit-duration: #{m3-motion.duration('short2')};
--m3-popper-enter-easing: #{m3-motion.easing('standard-decelerate')};
--m3-popper-exit-easing: #{m3-motion.easing('standard-accelerate')};
--m3-popper-enter-x: 0px;
--m3-popper-enter-y: 0px;
--m3-popper-origin-x: center;
--m3-popper-origin-y: top;
--m3-popper-scale-x-hidden: 0.96;
--m3-popper-scale-y-hidden: 0.96;

visibility: hidden;
opacity: 0;
transition:
m3-motion.timing-standard-decelerate(opacity),
m3-motion.timing-standard-decelerate(visibility)
opacity var(--m3-popper-opacity-exit-duration) var(--m3-popper-exit-easing),
visibility 0s linear var(--m3-popper-exit-duration)
;
position: absolute;
top: 0;
left: 0;

&_animated {
transform-origin: var(--m3-popper-origin-x) var(--m3-popper-origin-y);
transform: translate(var(--m3-popper-enter-x), var(--m3-popper-enter-y)) scale(var(--m3-popper-scale-x-hidden), var(--m3-popper-scale-y-hidden));
will-change: opacity, transform;
transition:
opacity var(--m3-popper-opacity-exit-duration) var(--m3-popper-exit-easing),
transform var(--m3-popper-exit-duration) var(--m3-popper-exit-easing),
visibility 0s linear var(--m3-popper-exit-duration)
;
}

&_animated#{&}_shown {
transform: translate(0, 0) scale(1);
transition:
opacity var(--m3-popper-opacity-enter-duration) var(--m3-popper-enter-easing),
transform var(--m3-popper-enter-duration) var(--m3-popper-enter-easing),
visibility 0s linear 0ms
;
}

&_shown {
visibility: visible;
opacity: 1;
transition:
m3-motion.timing-standard-accelerate(opacity),
m3-motion.timing-standard-accelerate(visibility)
opacity var(--m3-popper-opacity-enter-duration) var(--m3-popper-enter-easing),
visibility 0s linear 0ms
;
}
}
}

@media (prefers-reduced-motion: reduce) {
.m3-popper {
&_animated {
transform: translate(0, 0) scale(1);
transition:
opacity 0ms linear,
visibility 0s linear 0ms
;
}

&_animated#{&}_shown {
transition:
opacity 0ms linear,
visibility 0s linear 0ms
;
}
}
}
14 changes: 14 additions & 0 deletions m3-foundation/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,4 +92,18 @@ export default [
'max-lines-per-function': 'off',
},
},
{
files: [
'**/*.e2e.ts',
'**/*.e2e.tsx',
'**/*.e2e.test.ts',
'**/*.e2e.test.tsx',
'**/*.smote.ts',
'**/*.smoke.ts',
],
rules: {
'max-lines': 'off',
'max-lines-per-function': 'off',
},
},
]
38 changes: 29 additions & 9 deletions m3-foundation/lib/popper/floating.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ import {
shift,
} from '@floating-ui/dom'

type PopperSide = 'top' | 'bottom' | 'left' | 'right'

export type PopperPositionResult = {
placement: string;
side: PopperSide;
}

const computeMiddleware = (options: Required<FloatingOptions>) => {
const middleware: Middleware[] = []

Expand All @@ -34,22 +41,35 @@ const computeMiddleware = (options: Required<FloatingOptions>) => {
return middleware
}

const toSide = (placement: string): PopperSide => placement.split('-')[0] as PopperSide

const notifyWhenReferenceHidden = (
referenceHidden: boolean | undefined,
onReferenceHidden: () => void
) => {
if (referenceHidden) {
onReferenceHidden()
}
}

export const computePosition = async (el: HTMLElement, target: Element, options: Required<FloatingOptions> & {
onReferenceHidden: () => void
}) => {
const { strategy, x, y, middlewareData } = await _compute(target, el, {
}): Promise<PopperPositionResult> => {
const {
strategy,
x,
y,
middlewareData,
placement,
} = await _compute(target, el, {
middleware: computeMiddleware(options),
placement: options.placement,
strategy: options.strategy,
})

el.style.position = strategy
el.style.transform = `translate3d(${Math.round(x)}px,${Math.round(y)}px,0)`
notifyWhenReferenceHidden(middlewareData.hide?.referenceHidden, options.onReferenceHidden)

if (middlewareData.hide) {
const { referenceHidden } = middlewareData.hide
if (referenceHidden) {
options.onReferenceHidden()
}
}
}
return { placement, side: toSide(placement) }
}
3 changes: 2 additions & 1 deletion m3-foundation/types/components/popper.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export type ShowingOptions = {
shown?: boolean;
container?: Element | string;
disabled?: boolean;
animated?: boolean;
}

export type PopperOptions = FloatingOptions
Expand All @@ -62,4 +63,4 @@ export type CloserEvent<E extends Event = Event> = E & {
export type CloserTarget<E extends Element = Element> = E & {
m3PopperCloseAll?: boolean;
m3PopperCloserTouch?: Touch;
}
}
14 changes: 14 additions & 0 deletions m3-react/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -111,5 +111,19 @@ export default [
'max-lines-per-function': 'off',
},
},
{
files: [
'**/*.e2e.ts',
'**/*.e2e.tsx',
'**/*.e2e.test.ts',
'**/*.e2e.test.tsx',
'**/*.smote.ts',
'**/*.smoke.ts',
],
rules: {
'max-lines': 'off',
'max-lines-per-function': 'off',
},
},
{ ignores: ['dist/*'] },
]
1 change: 1 addition & 0 deletions m3-react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
"eslint-plugin-unused-imports": "^4.4.1",
"flag-icons": "^7.5.0",
"globals": "^17.3.0",
"highlight.js": "^11.11.1",
"jsdom": "^28.1.0",
"playwright": "^1.55.0",
"react": "^18.2.0",
Expand Down
1 change: 1 addition & 0 deletions m3-react/src/components/menu/M3Menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ const M3Menu: FC<M3MenuProps> = ({
offsetCrossAxis={offsetCrossAxis}
delay={delay}
disabled={disabled}
animated={true}
detachTimeout={detachTimeout}
className={toClassName(['m3-menu', className])}
hideOnMissClick={true}
Expand Down
Loading