mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 04:18:54 +00:00
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:
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
10
yarn.lock
10
yarn.lock
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user