From c9cd1efd8679c79bb6225193435170d54c91541d Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Tue, 14 Apr 2026 18:44:52 +0300 Subject: [PATCH] add close animation to web dropdown menu Uses the Web Animations API to animate the menu out (100ms fade + scale, ease-out). The close() method now accepts an optional callback that fires after the animation completes, matching the native Dialog close callback pattern. Also removes a leftover aria-label="Test" from the dropdown content. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/Menu/index.web.tsx | 100 +++++++++++++++++++++++------- 1 file changed, 76 insertions(+), 24 deletions(-) diff --git a/src/components/Menu/index.web.tsx b/src/components/Menu/index.web.tsx index ecb74a19e2..787156d22f 100644 --- a/src/components/Menu/index.web.tsx +++ b/src/components/Menu/index.web.tsx @@ -1,4 +1,13 @@ -import {forwardRef, useCallback, useId, useMemo, useState} from 'react' +import { + createContext, + forwardRef, + useCallback, + useContext, + useId, + useMemo, + useRef, + useState, +} from 'react' import { Pressable, type StyleProp, @@ -32,6 +41,8 @@ import { import {Portal} from '#/components/Portal' import {Text} from '#/components/Typography' +const ContentRefContext = createContext | null>(null) + export {useMenuContext} export function useMenuControl(): Dialog.DialogControlProps { @@ -62,12 +73,50 @@ export function Root({ }>) { const {_} = useLingui() const defaultControl = useMenuControl() + const {reduceMotionEnabled} = useA11y() + const contentRef = useRef(null) + const animRef = useRef(null) + + const rawControl = control || defaultControl + + const close = (cb?: () => void) => { + // If already animating, cancel and close immediately + if (animRef.current) { + animRef.current.cancel() + animRef.current = null + rawControl.close() + if (cb) setTimeout(cb) + return + } + + const el = contentRef.current as HTMLElement | null + if (el && !reduceMotionEnabled) { + const anim = el.animate( + [ + {opacity: 1, transform: 'scale(1)'}, + {opacity: 0, transform: 'scale(0.95)'}, + ], + {duration: 100, easing: 'ease-out', fill: 'forwards'}, + ) + animRef.current = anim + anim.onfinish = () => { + animRef.current = null + rawControl.close() + if (cb) setTimeout(cb) + } + } else { + rawControl.close() + if (cb) setTimeout(cb) + } + } + const context = useMemo( () => ({ - control: control || defaultControl, + control: {...rawControl, close}, }), - [control, defaultControl], + [rawControl, close], ) + const onOpenChange = useCallback( (open: boolean) => { if (context.control.isOpen && !open) { @@ -81,23 +130,25 @@ export function Root({ return ( - {context.control.isOpen && ( - - context.control.close()} - accessibilityHint="" - accessibilityLabel={_( - msg`Context menu backdrop, click to close the menu.`, - )} - /> - - )} - - {children} - + + {context.control.isOpen && ( + + context.control.close()} + accessibilityHint="" + accessibilityLabel={_( + msg`Context menu backdrop, click to close the menu.`, + )} + /> + + )} + + {children} + + ) } @@ -187,6 +238,7 @@ export function Outer({ }>) { const t = useTheme() const {reduceMotionEnabled} = useA11y() + const contentRef = useContext(ContentRefContext) return ( @@ -194,9 +246,9 @@ export function Outer({ sideOffset={5} collisionPadding={{left: 5, right: 5, bottom: 5}} loop - aria-label="Test" className="dropdown-menu-transform-origin dropdown-menu-constrain-size"> { - onPress(e) - /** * Ported forward from Radix * @see https://www.radix-ui.com/primitives/docs/components/dropdown-menu#item */ if (!e.defaultPrevented) { - control.close() + control.close(() => { + onPress(e) + }) } }} onFocus={onFocus}