Skip to content

Commit

Permalink
[Menu] Fix custom anchor positioning (#609)
Browse files Browse the repository at this point in the history
  • Loading branch information
michaldudak authored Sep 17, 2024
1 parent ac6b56c commit 1ab27b0
Show file tree
Hide file tree
Showing 5 changed files with 184 additions and 15 deletions.
14 changes: 12 additions & 2 deletions docs/app/experiments/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -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: {
Expand Down Expand Up @@ -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<Metadata> {
const { slug } = params;

return {
title: `${slug} - Experiments`,
};
}
33 changes: 33 additions & 0 deletions docs/app/experiments/menu-anchor-el.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement | null>(null);
const handleRef = React.useCallback((element: HTMLDivElement | null) => {
setAnchor(element);
}, []);

return (
<div>
<h1>Element passed to anchor</h1>
<Menu.Root animated={false}>
<Menu.Trigger>Trigger</Menu.Trigger>
<Menu.Positioner side="bottom" alignment="start" arrowPadding={0} anchor={anchorEl}>
<Menu.Popup>
<Menu.Item style={{ background: 'lightgray', padding: '5px' }}>One</Menu.Item>
<Menu.Item style={{ background: 'lightgray', padding: '5px' }}>Two</Menu.Item>
</Menu.Popup>
</Menu.Positioner>
</Menu.Root>
<div
data-testid="anchor"
style={{ margin: '100px', background: 'yellowgreen', height: '50px', width: '200px' }}
ref={handleRef}
>
Anchor
</div>
</div>
);
}
30 changes: 30 additions & 0 deletions docs/app/experiments/menu-anchor-ref.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(null);

return (
<div>
<h1>Ref passed to anchor</h1>
<Menu.Root animated={false}>
<Menu.Trigger>Trigger</Menu.Trigger>
<Menu.Positioner side="bottom" alignment="start" arrowPadding={0} anchor={anchor}>
<Menu.Popup>
<Menu.Item style={{ background: 'lightgray', padding: '5px' }}>One</Menu.Item>
<Menu.Item style={{ background: 'lightgray', padding: '5px' }}>Two</Menu.Item>
</Menu.Popup>
</Menu.Positioner>
</Menu.Root>
<div
data-testid="anchor"
style={{ margin: '100px', background: 'yellowgreen', height: '50px', width: '200px' }}
ref={anchor}
>
Anchor
</div>
</div>
);
}
84 changes: 83 additions & 1 deletion packages/mui-base/src/Menu/Positioner/MenuPositioner.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ describe('<Menu.Positioner />', () => {
}));

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();
}
Expand Down Expand Up @@ -83,6 +83,88 @@ describe('<Menu.Positioner />', () => {
);
});

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<HTMLDivElement | null>(null);
const handleRef = React.useCallback((element: HTMLDivElement | null) => {
setAnchor(element);
}, []);

return (
<div>
<Menu.Root open animated={false}>
<Menu.Positioner side="bottom" alignment="start" anchor={anchor} arrowPadding={0}>
<Menu.Popup>
<Menu.Item>1</Menu.Item>
<Menu.Item>2</Menu.Item>
</Menu.Popup>
</Menu.Positioner>
</Menu.Root>
<div data-testid="anchor" style={{ marginTop: '100px' }} ref={handleRef} />
</div>
);
}

const { getByRole, getByTestId } = await render(<TestComponent />);

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<HTMLDivElement | null>(null);
const handleRef = React.useCallback((element: HTMLDivElement | null) => {
setAnchor(element);
}, []);

const getAnchor = React.useCallback(() => anchor, [anchor]);

return (
<div>
<Menu.Root open animated={false}>
<Menu.Positioner side="bottom" alignment="start" anchor={getAnchor} arrowPadding={0}>
<Menu.Popup>
<Menu.Item>1</Menu.Item>
<Menu.Item>2</Menu.Item>
</Menu.Popup>
</Menu.Positioner>
</Menu.Root>
<div data-testid="anchor" style={{ marginTop: '100px' }} ref={handleRef} />
</div>
);
}

const { getByRole, getByTestId } = await render(<TestComponent />);

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();
Expand Down
38 changes: 26 additions & 12 deletions packages/mui-base/src/utils/useAnchorPositioning.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<Element | VirtualElement | null>(null);

useEnhancedEffect(() => {
function isRef(param: unknown): param is React.MutableRefObject<any> {
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) {
Expand Down Expand Up @@ -275,3 +283,9 @@ export function useAnchorPositioning(
],
);
}

function isRef(
param: Element | VirtualElement | React.RefObject<any> | null | undefined,
): param is React.RefObject<any> {
return param != null && 'current' in param;
}

0 comments on commit 1ab27b0

Please sign in to comment.