feat: close MainMenu and Library dropdown on item select (#6152)

This commit is contained in:
David Luzar 2023-01-23 16:54:35 +01:00 committed by GitHub
parent d4afd66268
commit 1db078a3dc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 178 additions and 62 deletions

View File

@ -0,0 +1,35 @@
import { atom, useAtom } from "jotai";
import { actionClearCanvas } from "../actions";
import { t } from "../i18n";
import { useExcalidrawActionManager } from "./App";
import ConfirmDialog from "./ConfirmDialog";
export const activeConfirmDialogAtom = atom<"clearCanvas" | null>(null);
export const ActiveConfirmDialog = () => {
const [activeConfirmDialog, setActiveConfirmDialog] = useAtom(
activeConfirmDialogAtom,
);
const actionManager = useExcalidrawActionManager();
if (!activeConfirmDialog) {
return null;
}
if (activeConfirmDialog === "clearCanvas") {
return (
<ConfirmDialog
onConfirm={() => {
actionManager.executeAction(actionClearCanvas);
setActiveConfirmDialog(null);
}}
onCancel={() => setActiveConfirmDialog(null)}
title={t("clearCanvasDialog.title")}
>
<p className="clear-canvas__content"> {t("alerts.clearReset")}</p>
</ConfirmDialog>
);
}
return null;
};

View File

@ -50,6 +50,7 @@ import { hostSidebarCountersAtom } from "./Sidebar/Sidebar";
import { jotaiScope } from "../jotai"; import { jotaiScope } from "../jotai";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import MainMenu from "./main-menu/MainMenu"; import MainMenu from "./main-menu/MainMenu";
import { ActiveConfirmDialog } from "./ActiveConfirmDialog";
import { HandButton } from "./HandButton"; import { HandButton } from "./HandButton";
import { isHandToolActive } from "../appState"; import { isHandToolActive } from "../appState";
@ -394,6 +395,7 @@ const LayerUI = ({
}} }}
/> />
)} )}
<ActiveConfirmDialog />
{renderImageExportDialog()} {renderImageExportDialog()}
{renderJSONExportDialog()} {renderJSONExportDialog()}
{appState.pasteDialog.shown && ( {appState.pasteDialog.shown && (

View File

@ -187,6 +187,7 @@ export const LibraryMenuHeader: React.FC<{
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
<DropdownMenu.Content <DropdownMenu.Content
onClickOutside={() => setIsLibraryMenuOpen(false)} onClickOutside={() => setIsLibraryMenuOpen(false)}
onSelect={() => setIsLibraryMenuOpen(false)}
className="library-menu" className="library-menu"
> >
{!itemsSelected && ( {!itemsSelected && (

View File

@ -4,16 +4,23 @@ import { Island } from "../Island";
import { useDevice } from "../App"; import { useDevice } from "../App";
import clsx from "clsx"; import clsx from "clsx";
import Stack from "../Stack"; import Stack from "../Stack";
import React from "react";
import { DropdownMenuContentPropsContext } from "./common";
const MenuContent = ({ const MenuContent = ({
children, children,
onClickOutside, onClickOutside,
className = "", className = "",
onSelect,
style, style,
}: { }: {
children?: React.ReactNode; children?: React.ReactNode;
onClickOutside?: () => void; onClickOutside?: () => void;
className?: string; className?: string;
/**
* Called when any menu item is selected (clicked on).
*/
onSelect?: (event: Event) => void;
style?: React.CSSProperties; style?: React.CSSProperties;
}) => { }) => {
const device = useDevice(); const device = useDevice();
@ -24,7 +31,9 @@ const MenuContent = ({
const classNames = clsx(`dropdown-menu ${className}`, { const classNames = clsx(`dropdown-menu ${className}`, {
"dropdown-menu--mobile": device.isMobile, "dropdown-menu--mobile": device.isMobile,
}).trim(); }).trim();
return ( return (
<DropdownMenuContentPropsContext.Provider value={{ onSelect }}>
<div <div
ref={menuRef} ref={menuRef}
className={classNames} className={classNames}
@ -45,7 +54,9 @@ const MenuContent = ({
</Island> </Island>
)} )}
</div> </div>
</DropdownMenuContentPropsContext.Provider>
); );
}; };
export default MenuContent;
MenuContent.displayName = "DropdownMenuContent"; MenuContent.displayName = "DropdownMenuContent";
export default MenuContent;

View File

@ -1,10 +1,10 @@
import React from "react"; import React from "react";
import {
getDrodownMenuItemClassName,
useHandleDropdownMenuItemClick,
} from "./common";
import MenuItemContent from "./DropdownMenuItemContent"; import MenuItemContent from "./DropdownMenuItemContent";
export const getDrodownMenuItemClassName = (className = "") => {
return `dropdown-menu-item dropdown-menu-item-base ${className}`.trim();
};
const DropdownMenuItem = ({ const DropdownMenuItem = ({
icon, icon,
onSelect, onSelect,
@ -14,15 +14,17 @@ const DropdownMenuItem = ({
...rest ...rest
}: { }: {
icon?: JSX.Element; icon?: JSX.Element;
onSelect: () => void; onSelect: (event: Event) => void;
children: React.ReactNode; children: React.ReactNode;
shortcut?: string; shortcut?: string;
className?: string; className?: string;
} & React.ButtonHTMLAttributes<HTMLButtonElement>) => { } & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onSelect">) => {
const handleClick = useHandleDropdownMenuItemClick(rest.onClick, onSelect);
return ( return (
<button <button
{...rest} {...rest}
onClick={onSelect} onClick={handleClick}
type="button" type="button"
className={getDrodownMenuItemClassName(className)} className={getDrodownMenuItemClassName(className)}
title={rest.title ?? rest["aria-label"]} title={rest.title ?? rest["aria-label"]}

View File

@ -1,20 +1,28 @@
import MenuItemContent from "./DropdownMenuItemContent"; import MenuItemContent from "./DropdownMenuItemContent";
import React from "react"; import React from "react";
import { getDrodownMenuItemClassName } from "./DropdownMenuItem"; import {
getDrodownMenuItemClassName,
useHandleDropdownMenuItemClick,
} from "./common";
const DropdownMenuItemLink = ({ const DropdownMenuItemLink = ({
icon, icon,
shortcut, shortcut,
href, href,
children, children,
onSelect,
className = "", className = "",
...rest ...rest
}: { }: {
href: string;
icon?: JSX.Element; icon?: JSX.Element;
children: React.ReactNode; children: React.ReactNode;
shortcut?: string; shortcut?: string;
className?: string; className?: string;
href: string; onSelect?: (event: Event) => void;
} & React.AnchorHTMLAttributes<HTMLAnchorElement>) => { } & React.AnchorHTMLAttributes<HTMLAnchorElement>) => {
const handleClick = useHandleDropdownMenuItemClick(rest.onClick, onSelect);
return ( return (
<a <a
{...rest} {...rest}
@ -23,6 +31,7 @@ const DropdownMenuItemLink = ({
rel="noreferrer" rel="noreferrer"
className={getDrodownMenuItemClassName(className)} className={getDrodownMenuItemClassName(className)}
title={rest.title ?? rest["aria-label"]} title={rest.title ?? rest["aria-label"]}
onClick={handleClick}
> >
<MenuItemContent icon={icon} shortcut={shortcut}> <MenuItemContent icon={icon} shortcut={shortcut}>
{children} {children}

View File

@ -0,0 +1,31 @@
import React, { useContext } from "react";
import { EVENT } from "../../constants";
import { composeEventHandlers } from "../../utils";
export const DropdownMenuContentPropsContext = React.createContext<{
onSelect?: (event: Event) => void;
}>({});
export const getDrodownMenuItemClassName = (className = "") => {
return `dropdown-menu-item dropdown-menu-item-base ${className}`.trim();
};
export const useHandleDropdownMenuItemClick = (
origOnClick:
| React.MouseEventHandler<HTMLAnchorElement | HTMLButtonElement>
| undefined,
onSelect: ((event: Event) => void) | undefined,
) => {
const DropdownMenuContentProps = useContext(DropdownMenuContentPropsContext);
return composeEventHandlers(origOnClick, (event) => {
const itemSelectEvent = new CustomEvent(EVENT.MENU_ITEM_SELECT, {
bubbles: true,
cancelable: true,
});
onSelect?.(itemSelectEvent);
if (!itemSelectEvent.defaultPrevented) {
DropdownMenuContentProps.onSelect?.(itemSelectEvent);
}
});
};

View File

@ -28,9 +28,9 @@ import {
} from "../../actions"; } from "../../actions";
import "./DefaultItems.scss"; import "./DefaultItems.scss";
import { useState } from "react";
import ConfirmDialog from "../ConfirmDialog";
import clsx from "clsx"; import clsx from "clsx";
import { useSetAtom } from "jotai";
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
export const LoadScene = () => { export const LoadScene = () => {
// FIXME Hack until we tie "t" to lang state // FIXME Hack until we tie "t" to lang state
@ -122,41 +122,22 @@ export const ClearCanvas = () => {
// FIXME Hack until we tie "t" to lang state // FIXME Hack until we tie "t" to lang state
// eslint-disable-next-line // eslint-disable-next-line
const appState = useExcalidrawAppState(); const appState = useExcalidrawAppState();
const setActiveConfirmDialog = useSetAtom(activeConfirmDialogAtom);
const actionManager = useExcalidrawActionManager(); const actionManager = useExcalidrawActionManager();
const [showDialog, setShowDialog] = useState(false);
const toggleDialog = () => setShowDialog(!showDialog);
if (!actionManager.isActionEnabled(actionClearCanvas)) { if (!actionManager.isActionEnabled(actionClearCanvas)) {
return null; return null;
} }
return ( return (
<>
<DropdownMenuItem <DropdownMenuItem
icon={TrashIcon} icon={TrashIcon}
onSelect={toggleDialog} onSelect={() => setActiveConfirmDialog("clearCanvas")}
data-testid="clear-canvas-button" data-testid="clear-canvas-button"
aria-label={t("buttons.clearReset")} aria-label={t("buttons.clearReset")}
> >
{t("buttons.clearReset")} {t("buttons.clearReset")}
</DropdownMenuItem> </DropdownMenuItem>
{/* FIXME this should live outside MainMenu so it stays open
if menu is closed */}
{showDialog && (
<ConfirmDialog
onConfirm={() => {
actionManager.executeAction(actionClearCanvas);
toggleDialog();
}}
onCancel={toggleDialog}
title={t("clearCanvasDialog.title")}
>
<p className="clear-canvas__content"> {t("alerts.clearReset")}</p>
</ConfirmDialog>
)}
</>
); );
}; };
ClearCanvas.displayName = "ClearCanvas"; ClearCanvas.displayName = "ClearCanvas";
@ -171,7 +152,9 @@ export const ToggleTheme = () => {
return ( return (
<DropdownMenuItem <DropdownMenuItem
onSelect={() => { onSelect={(event) => {
// do not close the menu when changing theme
event.preventDefault();
return actionManager.executeAction(actionToggleTheme); return actionManager.executeAction(actionToggleTheme);
}} }}
icon={appState.theme === "dark" ? SunIcon : MoonIcon} icon={appState.theme === "dark" ? SunIcon : MoonIcon}

View File

@ -11,14 +11,25 @@ import * as DefaultItems from "./DefaultItems";
import { UserList } from "../UserList"; import { UserList } from "../UserList";
import { t } from "../../i18n"; import { t } from "../../i18n";
import { HamburgerMenuIcon } from "../icons"; import { HamburgerMenuIcon } from "../icons";
import { composeEventHandlers } from "../../utils";
const MainMenu = ({ children }: { children?: React.ReactNode }) => { const MainMenu = ({
children,
onSelect,
}: {
children?: React.ReactNode;
/**
* Called when any menu item is selected (clicked on).
*/
onSelect?: (event: Event) => void;
}) => {
const device = useDevice(); const device = useDevice();
const appState = useExcalidrawAppState(); const appState = useExcalidrawAppState();
const setAppState = useExcalidrawSetAppState(); const setAppState = useExcalidrawSetAppState();
const onClickOutside = device.isMobile const onClickOutside = device.isMobile
? undefined ? undefined
: () => setAppState({ openMenu: null }); : () => setAppState({ openMenu: null });
return ( return (
<DropdownMenu open={appState.openMenu === "canvas"}> <DropdownMenu open={appState.openMenu === "canvas"}>
<DropdownMenu.Trigger <DropdownMenu.Trigger
@ -30,7 +41,12 @@ const MainMenu = ({ children }: { children?: React.ReactNode }) => {
> >
{HamburgerMenuIcon} {HamburgerMenuIcon}
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
<DropdownMenu.Content onClickOutside={onClickOutside}> <DropdownMenu.Content
onClickOutside={onClickOutside}
onSelect={composeEventHandlers(onSelect, () => {
setAppState({ openMenu: null });
})}
>
{children} {children}
{device.isMobile && appState.collaborators.size > 0 && ( {device.isMobile && appState.collaborators.size > 0 && (
<fieldset className="UserList-Wrapper"> <fieldset className="UserList-Wrapper">

View File

@ -62,6 +62,7 @@ export enum EVENT {
SCROLL = "scroll", SCROLL = "scroll",
// custom events // custom events
EXCALIDRAW_LINK = "excalidraw-link", EXCALIDRAW_LINK = "excalidraw-link",
MENU_ITEM_SELECT = "menu.itemSelect",
} }
export const ENV = { export const ENV = {

View File

@ -11,6 +11,12 @@ The change should be grouped under one of the below section and must contain PR
Please add the latest change on the top under the correct section. Please add the latest change on the top under the correct section.
--> -->
## Unreleased
### Features
- `MainMenu`, `MainMenu.Item`, and `MainMenu.ItemLink` components now all support `onSelect(event: Event): void` callback. If you call `event.preventDefault()`, it will prevent the menu from closing when an item is selected (clicked on). [#6152](https://github.com/excalidraw/excalidraw/pull/6152)
## 0.14.1 (2023-01-16) ## 0.14.1 (2023-01-16)
### Fixes ### Fixes

View File

@ -743,3 +743,22 @@ export const isShallowEqual = <T extends Record<string, any>>(
} }
return aKeys.every((key) => objA[key] === objB[key]); return aKeys.every((key) => objA[key] === objB[key]);
}; };
// taken from Radix UI
// https://github.com/radix-ui/primitives/blob/main/packages/core/primitive/src/primitive.tsx
export const composeEventHandlers = <E>(
originalEventHandler?: (event: E) => void,
ourEventHandler?: (event: E) => void,
{ checkForDefaultPrevented = true } = {},
) => {
return function handleEvent(event: E) {
originalEventHandler?.(event);
if (
!checkForDefaultPrevented ||
!(event as unknown as Event).defaultPrevented
) {
return ourEventHandler?.(event);
}
};
};