feat(component): dropdown menu auto avoid collisions (#8013)

<div class='graphite__hidden'>
          <div>🎥 Video uploaded on Graphite:</div>
            <a href="https://app.graphite.dev/media/video/g3jz87HxbjOJpXV3FPT7/7f9d21cc-7b2f-4dc1-801c-e69d5e6d0750.mp4">
              <img src="https://app.graphite.dev/api/v1/graphite/video/thumbnail/g3jz87HxbjOJpXV3FPT7/7f9d21cc-7b2f-4dc1-801c-e69d5e6d0750.mp4">
            </a>
          </div>
<video src="https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/g3jz87HxbjOJpXV3FPT7/7f9d21cc-7b2f-4dc1-801c-e69d5e6d0750.mp4">CleanShot 2024-08-29 at 14.49.58.mp4</video>
This commit is contained in:
EYHN
2024-09-03 02:12:16 +00:00
parent 197996de31
commit 02f0d7aa08
6 changed files with 136 additions and 13 deletions

View File

@@ -105,7 +105,7 @@
"@vanilla-extract/css": "^1.14.2", "@vanilla-extract/css": "^1.14.2",
"fake-indexeddb": "^6.0.0", "fake-indexeddb": "^6.0.0",
"storybook": "^8.2.9", "storybook": "^8.2.9",
"storybook-dark-mode": "4.0.2", "storybook-dark-mode": "4.0.1",
"typescript": "^5.4.5", "typescript": "^5.4.5",
"unplugin-swc": "^1.5.1", "unplugin-swc": "^1.5.1",
"vite": "^5.2.8", "vite": "^5.2.8",

View File

@@ -0,0 +1,87 @@
import { useCallback, useEffect, useRef, useState } from 'react';
export const useMenuContentController = ({
onOpenChange,
side,
defaultOpen,
sideOffset,
}: {
defaultOpen?: boolean;
side?: 'top' | 'bottom' | 'left' | 'right';
onOpenChange?: (open: boolean) => void;
sideOffset?: number;
} = {}) => {
const [open, setOpen] = useState(defaultOpen ?? false);
const contentSide = side ?? 'bottom';
const [contentOffset, setContentOffset] = useState<number>(0);
const contentRef = useRef<HTMLDivElement>(null);
const handleOpenChange = useCallback(
(open: boolean) => {
setOpen(open);
onOpenChange?.(open);
},
[onOpenChange]
);
useEffect(() => {
if (!open || !contentRef.current) return;
const wrapperElement = contentRef.current.parentNode as HTMLDivElement;
const updateContentOffset = () => {
if (!contentRef.current) return;
const contentRect = wrapperElement.getBoundingClientRect();
if (contentSide === 'bottom') {
setContentOffset(prev => {
const viewportHeight = window.innerHeight;
const newOffset = Math.min(
viewportHeight - (contentRect.bottom - prev),
0
);
return newOffset;
});
} else if (contentSide === 'top') {
setContentOffset(prev => {
const newOffset = Math.max(contentRect.top - prev, 0);
return newOffset;
});
} else if (contentSide === 'left') {
setContentOffset(prev => {
const newOffset = Math.max(contentRect.left - prev, 0);
return newOffset;
});
} else if (contentSide === 'right') {
setContentOffset(prev => {
const viewportWidth = window.innerWidth;
const newOffset = Math.min(
viewportWidth - (contentRect.right - prev),
0
);
return newOffset;
});
}
};
let animationFrame: number = 0;
const requestUpdateContentOffset = () => {
cancelAnimationFrame(animationFrame);
animationFrame = requestAnimationFrame(updateContentOffset);
};
const observer = new ResizeObserver(requestUpdateContentOffset);
observer.observe(wrapperElement);
window.addEventListener('resize', requestUpdateContentOffset);
requestUpdateContentOffset();
return () => {
observer.disconnect();
window.removeEventListener('resize', requestUpdateContentOffset);
cancelAnimationFrame(animationFrame);
};
}, [contentSide, open]);
return {
handleOpenChange,
contentSide,
contentOffset: (sideOffset ?? 0) + contentOffset,
contentRef,
};
};

View File

@@ -3,21 +3,35 @@ import clsx from 'clsx';
import type { MenuProps } from '../menu.types'; import type { MenuProps } from '../menu.types';
import * as styles from '../styles.css'; import * as styles from '../styles.css';
import { useMenuContentController } from './controller';
import * as desktopStyles from './styles.css'; import * as desktopStyles from './styles.css';
export const DesktopMenu = ({ export const DesktopMenu = ({
children, children,
items, items,
portalOptions, portalOptions,
rootOptions, rootOptions: { onOpenChange, defaultOpen, ...rootOptions } = {},
contentOptions: { contentOptions: {
className = '', className = '',
style: contentStyle = {}, style: contentStyle = {},
side,
sideOffset,
...otherContentOptions ...otherContentOptions
} = {}, } = {},
}: MenuProps) => { }: MenuProps) => {
const { handleOpenChange, contentSide, contentOffset, contentRef } =
useMenuContentController({
defaultOpen,
onOpenChange,
side,
sideOffset: (sideOffset ?? 0) + 5,
});
return ( return (
<DropdownMenu.Root {...rootOptions}> <DropdownMenu.Root
onOpenChange={handleOpenChange}
defaultOpen={defaultOpen}
{...rootOptions}
>
<DropdownMenu.Trigger asChild>{children}</DropdownMenu.Trigger> <DropdownMenu.Trigger asChild>{children}</DropdownMenu.Trigger>
<DropdownMenu.Portal {...portalOptions}> <DropdownMenu.Portal {...portalOptions}>
@@ -27,11 +41,13 @@ export const DesktopMenu = ({
desktopStyles.contentAnimation, desktopStyles.contentAnimation,
className className
)} )}
sideOffset={5}
align="start" align="start"
ref={contentRef}
side={contentSide}
style={{ zIndex: 'var(--affine-z-index-popover)', ...contentStyle }} style={{ zIndex: 'var(--affine-z-index-popover)', ...contentStyle }}
avoidCollisions={false}
sideOffset={contentOffset}
{...otherContentOptions} {...otherContentOptions}
side="bottom"
> >
{items} {items}
</DropdownMenu.Content> </DropdownMenu.Content>

View File

@@ -4,10 +4,12 @@ const slideDown = keyframes({
from: { from: {
opacity: 0, opacity: 0,
transform: 'translateY(-10px)', transform: 'translateY(-10px)',
pointerEvents: 'none',
}, },
to: { to: {
opacity: 1, opacity: 1,
transform: 'translateY(0)', transform: 'translateY(0)',
pointerEvents: 'none',
}, },
}); });

View File

@@ -6,15 +6,18 @@ import { useMemo } from 'react';
import type { MenuSubProps } from '../menu.types'; import type { MenuSubProps } from '../menu.types';
import * as styles from '../styles.css'; import * as styles from '../styles.css';
import { useMenuItem } from '../use-menu-item'; import { useMenuItem } from '../use-menu-item';
import { useMenuContentController } from './controller';
export const DesktopMenuSub = ({ export const DesktopMenuSub = ({
children: propsChildren, children: propsChildren,
items, items,
portalOptions, portalOptions,
subOptions, subOptions: { defaultOpen, onOpenChange, ...otherSubOptions } = {},
triggerOptions, triggerOptions,
subContentOptions: { subContentOptions: {
className: subContentClassName = '', className: subContentClassName = '',
sideOffset,
style: contentStyle,
...otherSubContentOptions ...otherSubContentOptions
} = {}, } = {},
}: MenuSubProps) => { }: MenuSubProps) => {
@@ -24,18 +27,33 @@ export const DesktopMenuSub = ({
suffixIcon: <ArrowRightSmallIcon />, suffixIcon: <ArrowRightSmallIcon />,
}); });
const { handleOpenChange, contentOffset, contentRef } =
useMenuContentController({
defaultOpen,
onOpenChange,
side: 'right',
sideOffset: (sideOffset ?? 0) + 12,
});
return ( return (
<DropdownMenu.Sub {...subOptions}> <DropdownMenu.Sub
defaultOpen={defaultOpen}
onOpenChange={handleOpenChange}
{...otherSubOptions}
>
<DropdownMenu.SubTrigger className={className} {...otherProps}> <DropdownMenu.SubTrigger className={className} {...otherProps}>
{children} {children}
</DropdownMenu.SubTrigger> </DropdownMenu.SubTrigger>
<DropdownMenu.Portal {...portalOptions}> <DropdownMenu.Portal {...portalOptions}>
<DropdownMenu.SubContent <DropdownMenu.SubContent
sideOffset={12} sideOffset={contentOffset}
ref={contentRef}
className={useMemo( className={useMemo(
() => clsx(styles.menuContent, subContentClassName), () => clsx(styles.menuContent, subContentClassName),
[subContentClassName] [subContentClassName]
)} )}
style={{ zIndex: 'var(--affine-z-index-popover)', ...contentStyle }}
avoidCollisions={false}
{...otherSubContentOptions} {...otherSubContentOptions}
> >
{items} {items}

View File

@@ -384,7 +384,7 @@ __metadata:
rxjs: "npm:^7.8.1" rxjs: "npm:^7.8.1"
sonner: "npm:^1.4.41" sonner: "npm:^1.4.41"
storybook: "npm:^8.2.9" storybook: "npm:^8.2.9"
storybook-dark-mode: "npm:4.0.2" storybook-dark-mode: "npm:4.0.1"
swr: "npm:^2.2.5" swr: "npm:^2.2.5"
typescript: "npm:^5.4.5" typescript: "npm:^5.4.5"
unplugin-swc: "npm:^1.5.1" unplugin-swc: "npm:^1.5.1"
@@ -33028,9 +33028,9 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"storybook-dark-mode@npm:4.0.2": "storybook-dark-mode@npm:4.0.1":
version: 4.0.2 version: 4.0.1
resolution: "storybook-dark-mode@npm:4.0.2" resolution: "storybook-dark-mode@npm:4.0.1"
dependencies: dependencies:
"@storybook/components": "npm:^8.0.0" "@storybook/components": "npm:^8.0.0"
"@storybook/core-events": "npm:^8.0.0" "@storybook/core-events": "npm:^8.0.0"
@@ -33040,7 +33040,7 @@ __metadata:
"@storybook/theming": "npm:^8.0.0" "@storybook/theming": "npm:^8.0.0"
fast-deep-equal: "npm:^3.1.3" fast-deep-equal: "npm:^3.1.3"
memoizerific: "npm:^1.11.3" memoizerific: "npm:^1.11.3"
checksum: 10/c9ef7bc6734df7486ff763c9da3c69505269eaf5fd7b5b489553f023b363ea892862241e6d701ad647ca5d1e64fd9a2646b8985c7ea8ac97a3bca87891db6fe5 checksum: 10/3225e5bdaba0ea76b65d642202d9712d7de234e3b5673fb46e444892ab114be207dd287778e2002b662ec35bb8153d2624ff280ce51c5299fb13c711431dad40
languageName: node languageName: node
linkType: hard linkType: hard