mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-21 20:16:26 +08:00
067469aa98
Continue [BS-2169](https://linear.app/affine-design/issue/BS-2169/新的-menu-不支持移动端)
178 lines
4.8 KiB
TypeScript
178 lines
4.8 KiB
TypeScript
import { useI18n } from '@affine/i18n';
|
|
import { ArrowLeftSmallIcon } from '@blocksuite/icons/rc';
|
|
import { Slot } from '@radix-ui/react-slot';
|
|
import clsx from 'clsx';
|
|
import { useCallback, useContext, useEffect, useState } from 'react';
|
|
|
|
import { observeResize } from '../../../utils';
|
|
import { Button } from '../../button';
|
|
import { Modal } from '../../modal';
|
|
import { Scrollable } from '../../scrollbar';
|
|
import type { MenuProps } from '../menu.types';
|
|
import {
|
|
MobileMenuContext,
|
|
type SubMenuContent,
|
|
useMobileSubMenuHelper,
|
|
} from './context';
|
|
import * as styles from './styles.css';
|
|
import { MobileMenuSubRaw } from './sub';
|
|
|
|
export const MobileMenu = ({
|
|
children,
|
|
items,
|
|
contentOptions: {
|
|
className,
|
|
onPointerDownOutside,
|
|
onInteractOutside,
|
|
// ignore the following props
|
|
sideOffset: _sideOffset,
|
|
side: _side,
|
|
align: _align,
|
|
|
|
...otherContentOptions
|
|
} = {},
|
|
rootOptions,
|
|
}: 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
|
|
useEffect(() => {
|
|
if (sliderElement && finalOpen) {
|
|
const active = sliderElement.querySelector(
|
|
`.${styles.menuContent}[data-index="${activeIndex}"]`
|
|
);
|
|
if (!active) return;
|
|
|
|
// for the situation that content is loaded asynchronously
|
|
return observeResize(active, entry => {
|
|
setSliderHeight(entry.borderBoxSize[0].blockSize);
|
|
});
|
|
}
|
|
return;
|
|
}, [activeIndex, finalOpen, sliderElement]);
|
|
|
|
const onOpenChange = useCallback(
|
|
(open: boolean) => {
|
|
if (!open) {
|
|
// a workaround to hack the onPointerDownOutside event
|
|
onPointerDownOutside?.({} as any);
|
|
onInteractOutside?.({} as any);
|
|
removeAllSubMenus();
|
|
}
|
|
setOpen(open);
|
|
rootOptions?.onOpenChange?.(open);
|
|
},
|
|
[onInteractOutside, onPointerDownOutside, removeAllSubMenus, rootOptions]
|
|
);
|
|
|
|
const onItemClick = useCallback(
|
|
(e: any) => {
|
|
e.preventDefault();
|
|
onOpenChange(!open);
|
|
},
|
|
[onOpenChange, open]
|
|
);
|
|
|
|
const t = useI18n();
|
|
|
|
/**
|
|
* For cascading menu usage
|
|
* ```tsx
|
|
* <Menu
|
|
* items={
|
|
* <Menu>Click me</Menu>
|
|
* }
|
|
* >
|
|
* Root
|
|
* </Menu>
|
|
* ```
|
|
*/
|
|
if (pSetOpen) {
|
|
return (
|
|
<MobileMenuSubRaw items={items} subOptions={rootOptions}>
|
|
{children}
|
|
</MobileMenuSubRaw>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Slot onClick={onItemClick}>{children}</Slot>
|
|
<MobileMenuContext.Provider
|
|
value={{ subMenus, setSubMenus, setOpen: onOpenChange }}
|
|
>
|
|
<Modal
|
|
open={finalOpen}
|
|
onOpenChange={onOpenChange}
|
|
width="100%"
|
|
animation="slideBottom"
|
|
withoutCloseButton={true}
|
|
contentOptions={{
|
|
className: clsx(className, styles.mobileMenuModal),
|
|
...otherContentOptions,
|
|
}}
|
|
disableAutoFocus={true}
|
|
>
|
|
<div
|
|
ref={setSliderElement}
|
|
className={styles.slider}
|
|
style={{
|
|
transform: `translateX(-${100 * activeIndex}%)`,
|
|
height: sliderHeight,
|
|
}}
|
|
>
|
|
<div data-index={0} className={styles.menuContent}>
|
|
{items}
|
|
</div>
|
|
{subMenus.map((sub, index) => (
|
|
<div
|
|
key={sub.id}
|
|
data-index={index + 1}
|
|
className={styles.menuContent}
|
|
>
|
|
<Button
|
|
data-testid="mobile-menu-back-button"
|
|
variant="plain"
|
|
className={styles.backButton}
|
|
prefix={<ArrowLeftSmallIcon />}
|
|
onClick={() => {
|
|
removeSubMenu(sub.id);
|
|
}}
|
|
prefixStyle={{ width: 24, height: 24 }}
|
|
>
|
|
{sub.title || t['com.affine.backButton']()}
|
|
</Button>
|
|
<Scrollable.Root>
|
|
<Scrollable.Viewport className={styles.scrollArea}>
|
|
{sub.items}
|
|
</Scrollable.Viewport>
|
|
<Scrollable.Scrollbar />
|
|
</Scrollable.Root>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</Modal>
|
|
</MobileMenuContext.Provider>
|
|
</>
|
|
);
|
|
};
|