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 Dispatch,
type ReactNode, type ReactNode,
type SetStateAction, type SetStateAction,
useCallback,
useContext,
} from 'react'; } from 'react';
import type { MenuSubProps } from '../menu.types'; import type { MenuSubProps } from '../menu.types';
@@ -14,14 +16,61 @@ export type SubMenuContent = {
*/ */
title?: string; title?: string;
items: ReactNode; items: ReactNode;
options?: MenuSubProps['subOptions'];
contentOptions?: MenuSubProps['subContentOptions']; contentOptions?: MenuSubProps['subContentOptions'];
id: string;
}; };
export const MobileMenuContext = createContext<{ export type MobileMenuContextValue = {
subMenus: Array<SubMenuContent>; subMenus: Array<SubMenuContent>;
setSubMenus: Dispatch<SetStateAction<Array<SubMenuContent>>>; setSubMenus: Dispatch<SetStateAction<Array<SubMenuContent>>>;
setOpen?: (v: boolean) => void; setOpen?: (v: boolean) => void;
}>({ };
export const MobileMenuContext = createContext<MobileMenuContextValue>({
subMenus: [], subMenus: [],
setSubMenus: () => {}, 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) => { export const MobileMenuItem = (props: MenuItemProps) => {
const { setOpen } = useContext(MobileMenuContext); const { setOpen, subMenus, setSubMenus } = useContext(MobileMenuContext);
const { className, children, otherProps } = useMenuItem(props); const { className, children, otherProps } = useMenuItem(props);
const { onSelect, onClick, divide, ...restProps } = otherProps; const { onSelect, onClick, divide, ...restProps } = otherProps;
@@ -21,10 +21,16 @@ export const MobileMenuItem = (props: MenuItemProps) => {
if (preventDefaultFlag) { if (preventDefaultFlag) {
preventDefaultFlag = false; preventDefaultFlag = false;
} else { } 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 ( return (

View File

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

View File

@@ -1,10 +1,10 @@
import { ArrowRightSmallPlusIcon } from '@blocksuite/icons/rc'; import { ArrowRightSmallPlusIcon } from '@blocksuite/icons/rc';
import { Slot } from '@radix-ui/react-slot'; 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 type { MenuSubProps } from '../menu.types';
import { useMenuItem } from '../use-menu-item'; import { useMenuItem } from '../use-menu-item';
import { MobileMenuContext } from './context'; import { useMobileSubMenuHelper } from './context';
export const MobileMenuSub = ({ export const MobileMenuSub = ({
title, title,
@@ -42,20 +42,36 @@ export const MobileMenuSubRaw = ({
onClick, onClick,
children, children,
items, items,
subOptions,
subContentOptions: contentOptions = {}, subContentOptions: contentOptions = {},
}: MenuSubProps & { }: MenuSubProps & {
onClick?: (e: MouseEvent<HTMLDivElement>) => void; onClick?: (e: MouseEvent<HTMLDivElement>) => void;
title?: string; 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( const onItemClick = useCallback(
(e: MouseEvent<HTMLDivElement>) => { (e: MouseEvent<HTMLDivElement>) => {
onClick?.(e); 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>; return <Slot onClick={onItemClick}>{children}</Slot>;
}; };

View File

@@ -250,7 +250,7 @@ const DocWorkspacePropertiesTableBody = forwardRef<
const workbenchService = useService(WorkbenchService); const workbenchService = useService(WorkbenchService);
const viewService = useServiceOptional(ViewService); const viewService = useServiceOptional(ViewService);
const properties = useLiveData(docsService.propertyList.sortedProperties$); const properties = useLiveData(docsService.propertyList.sortedProperties$);
const [propertyCollapsed, setPropertyCollapsed] = useState(true); const [addMoreCollapsed, setAddMoreCollapsed] = useState(true);
const [newPropertyId, setNewPropertyId] = useState<string | null>(null); const [newPropertyId, setNewPropertyId] = useState<string | null>(null);
@@ -262,6 +262,10 @@ const DocWorkspacePropertiesTableBody = forwardRef<
[onPropertyAdded] [onPropertyAdded]
); );
const handleCollapseChange = useCallback(() => {
setNewPropertyId(null);
}, []);
return ( return (
<PropertyCollapsibleSection <PropertyCollapsibleSection
ref={ref} ref={ref}
@@ -269,12 +273,13 @@ const DocWorkspacePropertiesTableBody = forwardRef<
style={style} style={style}
title={t.t('com.affine.workspace.properties')} title={t.t('com.affine.workspace.properties')}
defaultCollapsed={!defaultOpen} defaultCollapsed={!defaultOpen}
onCollapseChange={handleCollapseChange}
{...props} {...props}
> >
<PropertyCollapsibleContent <PropertyCollapsibleContent
collapsible collapsible
collapsed={propertyCollapsed} collapsed={addMoreCollapsed}
onCollapseChange={setPropertyCollapsed} onCollapseChange={setAddMoreCollapsed}
className={styles.tableBodySortable} className={styles.tableBodySortable}
collapseButtonText={({ hide, isCollapsed }) => collapseButtonText={({ hide, isCollapsed }) =>
isCollapsed isCollapsed

View File

@@ -1,5 +1,13 @@
import { test } from '@affine-test/kit/mobile'; import { test } from '@affine-test/kit/mobile';
import { expect } from '@playwright/test'; import { expect, type Page } from '@playwright/test';
const openDocInfoModal = async (page: Page) => {
await page.click('[data-testid="detail-page-header-more-button"]');
await expect(page.getByRole('dialog')).toBeVisible();
await page.getByRole('menuitem', { name: 'view info' }).click();
await expect(page.getByTestId('mobile-menu-back-button')).toBeVisible();
};
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
const docsTab = page.locator('#app-tabs').getByRole('tab', { name: 'all' }); const docsTab = page.locator('#app-tabs').getByRole('tab', { name: 'all' });
@@ -22,13 +30,28 @@ test('switch to page mode', async ({ page }) => {
await expect(page.locator('.doc-title-container')).toBeVisible(); await expect(page.locator('.doc-title-container')).toBeVisible();
}); });
test('doc info', async ({ page }) => { test('can show doc info', async ({ page }) => {
await page.click('[data-testid="detail-page-header-more-button"]'); await openDocInfoModal(page);
await expect(page.getByRole('dialog')).toBeVisible();
await page.getByRole('menuitem', { name: 'view info' }).click();
await expect(page.getByTestId('mobile-menu-back-button')).toBeVisible();
await expect(page.getByRole('dialog')).toContainText('Created'); await expect(page.getByRole('dialog')).toContainText('Created');
await expect(page.getByRole('dialog')).toContainText('Updated'); await expect(page.getByRole('dialog')).toContainText('Updated');
}); });
test('can add text property', async ({ page }) => {
await openDocInfoModal(page);
await expect(
page.getByRole('button', { name: 'Add property' })
).toBeVisible();
await page.getByRole('button', { name: 'Add property' }).click();
await page.getByRole('menuitem', { name: 'Text' }).click();
await expect(
page.getByTestId('mobile-menu-back-button').last()
).toBeVisible();
await page.getByTestId('mobile-menu-back-button').last().click();
await expect(page.getByTestId('mobile-menu-back-button')).toContainText(
'Write, Draw, Plan all at Once'
);
});