fix(core): multi sub menu layer handling (#8916)

fix AF-1800, AF-1801

<div class='graphite__hidden'>
          <div>🎥 Video uploaded on Graphite:</div>
            <a href="https://app.graphite.dev/media/video/T2klNLEk0wxLh4NRDzhk/7523df2b-2326-4878-b37a-d16e4275858d.mp4">
              <img src="https://app.graphite.dev/api/v1/graphite/video/thumbnail/T2klNLEk0wxLh4NRDzhk/7523df2b-2326-4878-b37a-d16e4275858d.mp4">
            </a>
          </div>
<video src="https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/T2klNLEk0wxLh4NRDzhk/7523df2b-2326-4878-b37a-d16e4275858d.mp4">20241126-0806-30.0904958.mp4</video>
This commit is contained in:
pengx17
2024-11-26 08:39:09 +00:00
parent e12d5f8750
commit d87a6f7068
6 changed files with 147 additions and 28 deletions

View File

@@ -3,6 +3,8 @@ import {
type Dispatch,
type ReactNode,
type SetStateAction,
useCallback,
useContext,
} from 'react';
import type { MenuSubProps } from '../menu.types';
@@ -14,14 +16,61 @@ export type SubMenuContent = {
*/
title?: string;
items: ReactNode;
options?: MenuSubProps['subOptions'];
contentOptions?: MenuSubProps['subContentOptions'];
id: string;
};
export const MobileMenuContext = createContext<{
export type MobileMenuContextValue = {
subMenus: Array<SubMenuContent>;
setSubMenus: Dispatch<SetStateAction<Array<SubMenuContent>>>;
setOpen?: (v: boolean) => void;
}>({
};
export const MobileMenuContext = createContext<MobileMenuContextValue>({
subMenus: [],
setSubMenus: () => {},
});
export const useMobileSubMenuHelper = (
contextValue?: MobileMenuContextValue
) => {
const _context = useContext(MobileMenuContext);
const { subMenus, setSubMenus } = contextValue ?? _context;
const addSubMenu = useCallback(
(subMenu: SubMenuContent) => {
const id = subMenu.id;
// if the submenu already exists, do nothing
if (subMenus.some(sub => sub.id === id)) {
return;
}
subMenu.options?.onOpenChange?.(true);
setSubMenus(prev => {
return [...prev, subMenu];
});
},
[setSubMenus, subMenus]
);
const removeSubMenu = useCallback(
(id: string) => {
setSubMenus(prev => {
const index = prev.findIndex(sub => sub.id === id);
prev[index]?.options?.onOpenChange?.(false);
return prev.filter(sub => sub.id !== id);
});
},
[setSubMenus]
);
const removeAllSubMenus = useCallback(() => {
setSubMenus([]);
}, [setSubMenus]);
return {
addSubMenu,
removeSubMenu,
removeAllSubMenus,
};
};

View File

@@ -10,7 +10,7 @@ const preventDefault = () => {
};
export const MobileMenuItem = (props: MenuItemProps) => {
const { setOpen } = useContext(MobileMenuContext);
const { setOpen, subMenus, setSubMenus } = useContext(MobileMenuContext);
const { className, children, otherProps } = useMenuItem(props);
const { onSelect, onClick, divide, ...restProps } = otherProps;
@@ -21,10 +21,16 @@ export const MobileMenuItem = (props: MenuItemProps) => {
if (preventDefaultFlag) {
preventDefaultFlag = false;
} else {
setOpen?.(false);
if (subMenus.length > 1) {
// assume the user can only click the last menu
// (mimic the back button)
setSubMenus(subMenus.slice(0, -1));
} else {
setOpen?.(false);
}
}
},
[onClick, onSelect, setOpen]
[onClick, onSelect, setOpen, setSubMenus, subMenus]
);
return (

View File

@@ -9,8 +9,11 @@ import { Button } from '../../button';
import { Modal } from '../../modal';
import { Scrollable } from '../../scrollbar';
import type { MenuProps } from '../menu.types';
import type { SubMenuContent } from './context';
import { MobileMenuContext } from './context';
import {
MobileMenuContext,
type SubMenuContent,
useMobileSubMenuHelper,
} from './context';
import * as styles from './styles.css';
import { MobileMenuSubRaw } from './sub';
@@ -32,12 +35,23 @@ export const MobileMenu = ({
}: MenuProps) => {
const [subMenus, setSubMenus] = useState<SubMenuContent[]>([]);
const [open, setOpen] = useState(false);
const mobileContextValue = {
subMenus,
setSubMenus,
setOpen,
};
const { removeSubMenu, removeAllSubMenus } =
useMobileSubMenuHelper(mobileContextValue);
const [sliderHeight, setSliderHeight] = useState(0);
const [sliderElement, setSliderElement] = useState<HTMLDivElement | null>(
null
);
const { setOpen: pSetOpen } = useContext(MobileMenuContext);
const finalOpen = rootOptions?.open ?? open;
// always show the last submenu, if any
const activeIndex = subMenus.length;
// dynamic height for slider
@@ -62,12 +76,12 @@ export const MobileMenu = ({
// a workaround to hack the onPointerDownOutside event
onPointerDownOutside?.({} as any);
onInteractOutside?.({} as any);
setSubMenus([]);
removeAllSubMenus();
}
setOpen(open);
rootOptions?.onOpenChange?.(open);
},
[onInteractOutside, onPointerDownOutside, rootOptions]
[onInteractOutside, onPointerDownOutside, removeAllSubMenus, rootOptions]
);
const onItemClick = useCallback(
@@ -93,7 +107,11 @@ export const MobileMenu = ({
* ```
*/
if (pSetOpen) {
return <MobileMenuSubRaw items={items}>{children}</MobileMenuSubRaw>;
return (
<MobileMenuSubRaw items={items} subOptions={rootOptions}>
{children}
</MobileMenuSubRaw>
);
}
return (
@@ -126,7 +144,7 @@ export const MobileMenu = ({
</div>
{subMenus.map((sub, index) => (
<div
key={index}
key={sub.id}
data-index={index + 1}
className={styles.menuContent}
>
@@ -135,7 +153,9 @@ export const MobileMenu = ({
variant="plain"
className={styles.backButton}
prefix={<ArrowLeftSmallIcon />}
onClick={() => setSubMenus(prev => prev.slice(0, index))}
onClick={() => {
removeSubMenu(sub.id);
}}
prefixStyle={{ width: 24, height: 24 }}
>
{sub.title || t['com.affine.backButton']()}

View File

@@ -1,10 +1,10 @@
import { ArrowRightSmallPlusIcon } from '@blocksuite/icons/rc';
import { Slot } from '@radix-ui/react-slot';
import { type MouseEvent, useCallback, useContext } from 'react';
import { type MouseEvent, useCallback, useEffect, useId, useMemo } from 'react';
import type { MenuSubProps } from '../menu.types';
import { useMenuItem } from '../use-menu-item';
import { MobileMenuContext } from './context';
import { useMobileSubMenuHelper } from './context';
export const MobileMenuSub = ({
title,
@@ -42,20 +42,36 @@ export const MobileMenuSubRaw = ({
onClick,
children,
items,
subOptions,
subContentOptions: contentOptions = {},
}: MenuSubProps & {
onClick?: (e: MouseEvent<HTMLDivElement>) => void;
title?: string;
}) => {
const { setSubMenus } = useContext(MobileMenuContext);
const id = useId();
const { addSubMenu } = useMobileSubMenuHelper();
const subMenuContent = useMemo(
() => ({ items, contentOptions, options: subOptions, title, id }),
[items, contentOptions, subOptions, title, id]
);
const doAddSubMenu = useCallback(() => {
addSubMenu(subMenuContent);
}, [addSubMenu, subMenuContent]);
const onItemClick = useCallback(
(e: MouseEvent<HTMLDivElement>) => {
onClick?.(e);
setSubMenus(prev => [...prev, { items, contentOptions, title }]);
doAddSubMenu();
},
[contentOptions, items, onClick, setSubMenus, title]
[doAddSubMenu, onClick]
);
useEffect(() => {
if (subOptions?.open) {
doAddSubMenu();
}
}, [doAddSubMenu, subOptions]);
return <Slot onClick={onItemClick}>{children}</Slot>;
};