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:
@@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
@@ -20,11 +20,17 @@ export const MobileMenuItem = (props: MenuItemProps) => {
|
|||||||
onClick?.({ ...e, preventDefault });
|
onClick?.({ ...e, preventDefault });
|
||||||
if (preventDefaultFlag) {
|
if (preventDefaultFlag) {
|
||||||
preventDefaultFlag = false;
|
preventDefaultFlag = false;
|
||||||
|
} else {
|
||||||
|
if (subMenus.length > 1) {
|
||||||
|
// assume the user can only click the last menu
|
||||||
|
// (mimic the back button)
|
||||||
|
setSubMenus(subMenus.slice(0, -1));
|
||||||
} else {
|
} else {
|
||||||
setOpen?.(false);
|
setOpen?.(false);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[onClick, onSelect, setOpen]
|
[onClick, onSelect, setOpen, setSubMenus, subMenus]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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']()}
|
||||||
|
|||||||
@@ -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>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user