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) <noreply@anthropic.com>
This commit is contained in:
Samuel Newman
2026-04-14 18:44:52 +03:00
parent eee2df6d3a
commit c9cd1efd86
+76 -24
View File
@@ -1,4 +1,13 @@
import {forwardRef, useCallback, useId, useMemo, useState} from 'react' import {
createContext,
forwardRef,
useCallback,
useContext,
useId,
useMemo,
useRef,
useState,
} from 'react'
import { import {
Pressable, Pressable,
type StyleProp, type StyleProp,
@@ -32,6 +41,8 @@ import {
import {Portal} from '#/components/Portal' import {Portal} from '#/components/Portal'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
const ContentRefContext = createContext<React.RefObject<any> | null>(null)
export {useMenuContext} export {useMenuContext}
export function useMenuControl(): Dialog.DialogControlProps { export function useMenuControl(): Dialog.DialogControlProps {
@@ -62,12 +73,50 @@ export function Root({
}>) { }>) {
const {_} = useLingui() const {_} = useLingui()
const defaultControl = useMenuControl() const defaultControl = useMenuControl()
const {reduceMotionEnabled} = useA11y()
const contentRef = useRef<any>(null)
const animRef = useRef<Animation | null>(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<ContextType>( const context = useMemo<ContextType>(
() => ({ () => ({
control: control || defaultControl, control: {...rawControl, close},
}), }),
[control, defaultControl], [rawControl, close],
) )
const onOpenChange = useCallback( const onOpenChange = useCallback(
(open: boolean) => { (open: boolean) => {
if (context.control.isOpen && !open) { if (context.control.isOpen && !open) {
@@ -81,23 +130,25 @@ export function Root({
return ( return (
<Context.Provider value={context}> <Context.Provider value={context}>
{context.control.isOpen && ( <ContentRefContext.Provider value={contentRef}>
<Portal> {context.control.isOpen && (
<Pressable <Portal>
style={[a.fixed, a.inset_0, a.z_50]} <Pressable
onPress={() => context.control.close()} style={[a.fixed, a.inset_0, a.z_50]}
accessibilityHint="" onPress={() => context.control.close()}
accessibilityLabel={_( accessibilityHint=""
msg`Context menu backdrop, click to close the menu.`, accessibilityLabel={_(
)} msg`Context menu backdrop, click to close the menu.`,
/> )}
</Portal> />
)} </Portal>
<DropdownMenu.Root )}
open={context.control.isOpen} <DropdownMenu.Root
onOpenChange={onOpenChange}> open={context.control.isOpen}
{children} onOpenChange={onOpenChange}>
</DropdownMenu.Root> {children}
</DropdownMenu.Root>
</ContentRefContext.Provider>
</Context.Provider> </Context.Provider>
) )
} }
@@ -187,6 +238,7 @@ export function Outer({
}>) { }>) {
const t = useTheme() const t = useTheme()
const {reduceMotionEnabled} = useA11y() const {reduceMotionEnabled} = useA11y()
const contentRef = useContext(ContentRefContext)
return ( return (
<DropdownMenu.Portal> <DropdownMenu.Portal>
@@ -194,9 +246,9 @@ export function Outer({
sideOffset={5} sideOffset={5}
collisionPadding={{left: 5, right: 5, bottom: 5}} collisionPadding={{left: 5, right: 5, bottom: 5}}
loop loop
aria-label="Test"
className="dropdown-menu-transform-origin dropdown-menu-constrain-size"> className="dropdown-menu-transform-origin dropdown-menu-constrain-size">
<View <View
ref={contentRef}
style={[ style={[
a.rounded_sm, a.rounded_sm,
a.p_xs, a.p_xs,
@@ -243,14 +295,14 @@ export function Item({children, label, onPress, style, ...rest}: ItemProps) {
accessibilityHint="" accessibilityHint=""
accessibilityLabel={label} accessibilityLabel={label}
onPress={e => { onPress={e => {
onPress(e)
/** /**
* Ported forward from Radix * Ported forward from Radix
* @see https://www.radix-ui.com/primitives/docs/components/dropdown-menu#item * @see https://www.radix-ui.com/primitives/docs/components/dropdown-menu#item
*/ */
if (!e.defaultPrevented) { if (!e.defaultPrevented) {
control.close() control.close(() => {
onPress(e)
})
} }
}} }}
onFocus={onFocus} onFocus={onFocus}