From 1ab27b0510e0ba19157597f17642022a6208ae09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Dudak?= Date: Tue, 17 Sep 2024 11:47:39 +0200 Subject: [PATCH] [Menu] Fix custom anchor positioning (#609) --- docs/app/experiments/[slug]/page.tsx | 14 +++- docs/app/experiments/menu-anchor-el.tsx | 33 ++++++++ docs/app/experiments/menu-anchor-ref.tsx | 30 +++++++ .../Menu/Positioner/MenuPositioner.test.tsx | 84 ++++++++++++++++++- .../src/utils/useAnchorPositioning.ts | 38 ++++++--- 5 files changed, 184 insertions(+), 15 deletions(-) create mode 100644 docs/app/experiments/menu-anchor-el.tsx create mode 100644 docs/app/experiments/menu-anchor-ref.tsx diff --git a/docs/app/experiments/[slug]/page.tsx b/docs/app/experiments/[slug]/page.tsx index 60ed954ea..ab2f3df3a 100644 --- a/docs/app/experiments/[slug]/page.tsx +++ b/docs/app/experiments/[slug]/page.tsx @@ -1,7 +1,9 @@ +import * as React from 'react'; +import { type Metadata } from 'next'; import { notFound } from 'next/navigation'; import { type Dirent } from 'node:fs'; +import { basename, extname } from 'node:path'; import { readdir } from 'node:fs/promises'; -import * as React from 'react'; interface Props { params: { @@ -31,5 +33,13 @@ export async function generateStaticParams() { entry.name !== 'layout.tsx' && entry.isFile(), ) - .map((entry: Dirent) => ({ slug: entry.name })); + .map((entry: Dirent) => ({ slug: basename(entry.name, extname(entry.name)) })); +} + +export async function generateMetadata({ params }: Props): Promise { + const { slug } = params; + + return { + title: `${slug} - Experiments`, + }; } diff --git a/docs/app/experiments/menu-anchor-el.tsx b/docs/app/experiments/menu-anchor-el.tsx new file mode 100644 index 000000000..883bd9a72 --- /dev/null +++ b/docs/app/experiments/menu-anchor-el.tsx @@ -0,0 +1,33 @@ +'use client'; + +import * as React from 'react'; +import * as Menu from '@base_ui/react/Menu'; + +export default function Page() { + const [anchorEl, setAnchor] = React.useState(null); + const handleRef = React.useCallback((element: HTMLDivElement | null) => { + setAnchor(element); + }, []); + + return ( +
+

Element passed to anchor

+ + Trigger + + + One + Two + + + +
+ Anchor +
+
+ ); +} diff --git a/docs/app/experiments/menu-anchor-ref.tsx b/docs/app/experiments/menu-anchor-ref.tsx new file mode 100644 index 000000000..e375b201d --- /dev/null +++ b/docs/app/experiments/menu-anchor-ref.tsx @@ -0,0 +1,30 @@ +'use client'; + +import * as React from 'react'; +import * as Menu from '@base_ui/react/Menu'; + +export default function Page() { + const anchor = React.useRef(null); + + return ( +
+

Ref passed to anchor

+ + Trigger + + + One + Two + + + +
+ Anchor +
+
+ ); +} diff --git a/packages/mui-base/src/Menu/Positioner/MenuPositioner.test.tsx b/packages/mui-base/src/Menu/Positioner/MenuPositioner.test.tsx index 440d548e1..a4ee8750b 100644 --- a/packages/mui-base/src/Menu/Positioner/MenuPositioner.test.tsx +++ b/packages/mui-base/src/Menu/Positioner/MenuPositioner.test.tsx @@ -46,7 +46,7 @@ describe('', () => { })); describe('prop: anchor', () => { - it('should be placed near the specified element', async function test() { + it('should be placed near the specified element when a ref is passed', async function test() { if (/jsdom/.test(window.navigator.userAgent)) { this.skip(); } @@ -83,6 +83,88 @@ describe('', () => { ); }); + it('should be placed near the specified element when an element is passed', async function test() { + if (/jsdom/.test(window.navigator.userAgent)) { + this.skip(); + } + + function TestComponent() { + const [anchor, setAnchor] = React.useState(null); + const handleRef = React.useCallback((element: HTMLDivElement | null) => { + setAnchor(element); + }, []); + + return ( +
+ + + + 1 + 2 + + + +
+
+ ); + } + + const { getByRole, getByTestId } = await render(); + + const popup = getByRole('menu'); + const anchor = getByTestId('anchor'); + + const anchorPosition = anchor.getBoundingClientRect(); + + await flushMicrotasks(); + + expect(popup.style.getPropertyValue('transform')).to.equal( + `translate(${anchorPosition.left}px, ${anchorPosition.bottom}px)`, + ); + }); + + it('should be placed near the specified element when a function returingn an element is passed', async function test() { + if (/jsdom/.test(window.navigator.userAgent)) { + this.skip(); + } + + function TestComponent() { + const [anchor, setAnchor] = React.useState(null); + const handleRef = React.useCallback((element: HTMLDivElement | null) => { + setAnchor(element); + }, []); + + const getAnchor = React.useCallback(() => anchor, [anchor]); + + return ( +
+ + + + 1 + 2 + + + +
+
+ ); + } + + const { getByRole, getByTestId } = await render(); + + const popup = getByRole('menu'); + const anchor = getByTestId('anchor'); + + const anchorPosition = anchor.getBoundingClientRect(); + + await flushMicrotasks(); + + expect(popup.style.getPropertyValue('transform')).to.equal( + `translate(${anchorPosition.left}px, ${anchorPosition.bottom}px)`, + ); + }); + it('should be placed at the specified position', async function test() { if (/jsdom/.test(window.navigator.userAgent)) { this.skip(); diff --git a/packages/mui-base/src/utils/useAnchorPositioning.ts b/packages/mui-base/src/utils/useAnchorPositioning.ts index ee15ef958..55fd04438 100644 --- a/packages/mui-base/src/utils/useAnchorPositioning.ts +++ b/packages/mui-base/src/utils/useAnchorPositioning.ts @@ -19,9 +19,7 @@ import { type FloatingContext, } from '@floating-ui/react'; import { getSide, getAlignment } from '@floating-ui/utils'; -import { isElement } from '@floating-ui/utils/dom'; import { useEnhancedEffect } from './useEnhancedEffect'; -import { useLatestRef } from './useLatestRef'; export type Side = 'top' | 'bottom' | 'left' | 'right'; export type Alignment = 'start' | 'center' | 'end'; @@ -205,20 +203,30 @@ export function useAnchorPositioning( nodeId, }); - // We can assume that element anchors are stable across renders, and thus can be reactive. - const reactiveAnchorDep = anchor == null || isElement(anchor); - const anchorRef = useLatestRef(anchor); + const registeredPositionReferenceRef = React.useRef(null); useEnhancedEffect(() => { - function isRef(param: unknown): param is React.MutableRefObject { - return {}.hasOwnProperty.call(param, 'current'); + const resolvedAnchor = typeof anchor === 'function' ? anchor() : anchor; + + if (resolvedAnchor) { + const unwrappedElement = isRef(resolvedAnchor) ? resolvedAnchor.current : resolvedAnchor; + refs.setPositionReference(unwrappedElement); + registeredPositionReferenceRef.current = unwrappedElement; } - const resolvedAnchor = - typeof anchorRef.current === 'function' ? anchorRef.current() : anchorRef.current; - if (resolvedAnchor && !isElement(resolvedAnchor)) { - refs.setPositionReference(isRef(resolvedAnchor) ? resolvedAnchor.current : resolvedAnchor); + }, [refs, anchor]); + + React.useEffect(() => { + // Refs from parent components are set after useLayoutEffect runs and are available in useEffect. + // Therefore, if the anchor is a ref, we need to update the position reference in useEffect. + if (typeof anchor === 'function') { + return; } - }, [refs, anchorRef, reactiveAnchorDep]); + + if (isRef(anchor) && anchor.current !== registeredPositionReferenceRef.current) { + refs.setPositionReference(anchor.current); + registeredPositionReferenceRef.current = anchor.current; + } + }, [refs, anchor]); React.useEffect(() => { if (keepMounted && mounted && elements.domReference && elements.floating) { @@ -275,3 +283,9 @@ export function useAnchorPositioning( ], ); } + +function isRef( + param: Element | VirtualElement | React.RefObject | null | undefined, +): param is React.RefObject { + return param != null && 'current' in param; +}