mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Implement right-click context menu (#9883)
* base context menu * extract document actions to `useDocumentMenuAction` hook * context menu for DocumentListItem * common menu component * use Menu in dropdown and context * menu context * remove DropdownMenu and ContextMenu primitives * update yarn.lock * permissions recent bug fix
This commit is contained in:
@@ -25,6 +25,10 @@ import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import DocumentMenu from "~/menus/DocumentMenu";
|
||||
import { documentPath } from "~/utils/routeHelpers";
|
||||
import { determineSidebarContext } from "./Sidebar/components/SidebarContext";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import { useDocumentMenuAction } from "~/hooks/useDocumentMenuAction";
|
||||
import { ContextMenu } from "./Menu/ContextMenu";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
document: Document;
|
||||
@@ -50,6 +54,7 @@ function DocumentListItem(
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const user = useCurrentUser();
|
||||
const { userMemberships, groupMemberships } = useStores();
|
||||
const locationSidebarContext = useLocationSidebarContext();
|
||||
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
|
||||
|
||||
@@ -78,87 +83,109 @@ function DocumentListItem(
|
||||
!!document.title.toLowerCase().includes(highlight.toLowerCase());
|
||||
const canStar = !document.isArchived && !document.isTemplate;
|
||||
|
||||
const isShared = !!(
|
||||
userMemberships.getByDocumentId(document.id) ||
|
||||
groupMemberships.getByDocumentId(document.id)
|
||||
);
|
||||
|
||||
const sidebarContext = determineSidebarContext({
|
||||
document,
|
||||
user,
|
||||
currentContext: locationSidebarContext,
|
||||
});
|
||||
|
||||
return (
|
||||
<DocumentLink
|
||||
ref={itemRef}
|
||||
dir={document.dir}
|
||||
role="menuitem"
|
||||
$isStarred={document.isStarred}
|
||||
$menuOpen={menuOpen}
|
||||
to={{
|
||||
pathname: documentPath(document),
|
||||
state: {
|
||||
title: document.titleWithDefault,
|
||||
sidebarContext,
|
||||
},
|
||||
}}
|
||||
{...rest}
|
||||
{...rovingTabIndex}
|
||||
>
|
||||
<Content>
|
||||
<Heading dir={document.dir}>
|
||||
{document.icon && (
|
||||
<>
|
||||
<Icon
|
||||
value={document.icon}
|
||||
color={document.color ?? undefined}
|
||||
initial={document.initial}
|
||||
/>
|
||||
|
||||
</>
|
||||
)}
|
||||
<Title
|
||||
text={document.titleWithDefault}
|
||||
highlight={highlight}
|
||||
dir={document.dir}
|
||||
/>
|
||||
{document.isBadgedNew && document.createdBy?.id !== user.id && (
|
||||
<Badge yellow>{t("New")}</Badge>
|
||||
)}
|
||||
{document.isDraft && showDraft && (
|
||||
<Tooltip content={t("Only visible to you")} placement="top">
|
||||
<Badge>{t("Draft")}</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canStar && (
|
||||
<StarPositioner>
|
||||
<StarButton document={document} />
|
||||
</StarPositioner>
|
||||
)}
|
||||
{document.isTemplate && showTemplate && (
|
||||
<Badge primary>{t("Template")}</Badge>
|
||||
)}
|
||||
</Heading>
|
||||
const actionContext = useActionContext({
|
||||
isContextMenu: true,
|
||||
activeDocumentId: document.id,
|
||||
activeCollectionId:
|
||||
!isShared && document.collectionId ? document.collectionId : undefined,
|
||||
});
|
||||
|
||||
{!queryIsInTitle && (
|
||||
<ResultContext
|
||||
text={context}
|
||||
highlight={highlight ? SEARCH_RESULT_REGEX : undefined}
|
||||
processResult={replaceResultMarks}
|
||||
const contextMenuAction = useDocumentMenuAction({ document });
|
||||
|
||||
return (
|
||||
<ContextMenu
|
||||
action={contextMenuAction}
|
||||
context={actionContext}
|
||||
ariaLabel={t("Document options")}
|
||||
onOpen={handleMenuOpen}
|
||||
onClose={handleMenuClose}
|
||||
>
|
||||
<DocumentLink
|
||||
ref={itemRef}
|
||||
dir={document.dir}
|
||||
role="menuitem"
|
||||
$isStarred={document.isStarred}
|
||||
$menuOpen={menuOpen}
|
||||
to={{
|
||||
pathname: documentPath(document),
|
||||
state: {
|
||||
title: document.titleWithDefault,
|
||||
sidebarContext,
|
||||
},
|
||||
}}
|
||||
{...rest}
|
||||
{...rovingTabIndex}
|
||||
>
|
||||
<Content>
|
||||
<Heading dir={document.dir}>
|
||||
{document.icon && (
|
||||
<>
|
||||
<Icon
|
||||
value={document.icon}
|
||||
color={document.color ?? undefined}
|
||||
initial={document.initial}
|
||||
/>
|
||||
|
||||
</>
|
||||
)}
|
||||
<Title
|
||||
text={document.titleWithDefault}
|
||||
highlight={highlight}
|
||||
dir={document.dir}
|
||||
/>
|
||||
{document.isBadgedNew && document.createdBy?.id !== user.id && (
|
||||
<Badge yellow>{t("New")}</Badge>
|
||||
)}
|
||||
{document.isDraft && showDraft && (
|
||||
<Tooltip content={t("Only visible to you")} placement="top">
|
||||
<Badge>{t("Draft")}</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canStar && (
|
||||
<StarPositioner>
|
||||
<StarButton document={document} />
|
||||
</StarPositioner>
|
||||
)}
|
||||
{document.isTemplate && showTemplate && (
|
||||
<Badge primary>{t("Template")}</Badge>
|
||||
)}
|
||||
</Heading>
|
||||
|
||||
{!queryIsInTitle && (
|
||||
<ResultContext
|
||||
text={context}
|
||||
highlight={highlight ? SEARCH_RESULT_REGEX : undefined}
|
||||
processResult={replaceResultMarks}
|
||||
/>
|
||||
)}
|
||||
<DocumentMeta
|
||||
document={document}
|
||||
showCollection={showCollection}
|
||||
showPublished={showPublished}
|
||||
showParentDocuments={showParentDocuments}
|
||||
showLastViewed
|
||||
/>
|
||||
)}
|
||||
<DocumentMeta
|
||||
document={document}
|
||||
showCollection={showCollection}
|
||||
showPublished={showPublished}
|
||||
showParentDocuments={showParentDocuments}
|
||||
showLastViewed
|
||||
/>
|
||||
</Content>
|
||||
<Actions>
|
||||
<DocumentMenu
|
||||
document={document}
|
||||
onOpen={handleMenuOpen}
|
||||
onClose={handleMenuClose}
|
||||
/>
|
||||
</Actions>
|
||||
</DocumentLink>
|
||||
</Content>
|
||||
<Actions>
|
||||
<DocumentMenu
|
||||
document={document}
|
||||
onOpen={handleMenuOpen}
|
||||
onClose={handleMenuClose}
|
||||
/>
|
||||
</Actions>
|
||||
</DocumentLink>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import * as React from "react";
|
||||
import { actionV2ToMenuItem } from "~/actions";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import { ActionContext, ActionV2Variant, ActionV2WithChildren } from "~/types";
|
||||
import { toMenuItems } from "./transformer";
|
||||
import { observer } from "mobx-react";
|
||||
import { useComputed } from "~/hooks/useComputed";
|
||||
import { Menu, MenuContent, MenuTrigger } from "~/components/primitives/Menu";
|
||||
import { MenuProvider } from "~/components/primitives/Menu/MenuContext";
|
||||
|
||||
type Props = {
|
||||
/** Root action with children representing the menu items */
|
||||
action: ActionV2WithChildren;
|
||||
/** Action context to use - new context will be created if not provided */
|
||||
context?: ActionContext;
|
||||
/** Trigger for the menu */
|
||||
children: React.ReactNode;
|
||||
/** ARIA label for the menu */
|
||||
ariaLabel: string;
|
||||
/** Callback when menu is opened */
|
||||
onOpen?: () => void;
|
||||
/** Callback when menu is closed */
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
export const ContextMenu = observer(
|
||||
({ action, children, ariaLabel, context, onOpen, onClose }: Props) => {
|
||||
const isMobile = useMobile();
|
||||
const contentRef = React.useRef<React.ElementRef<typeof MenuContent>>(null);
|
||||
|
||||
const actionContext =
|
||||
context ??
|
||||
useActionContext({
|
||||
isContextMenu: true,
|
||||
});
|
||||
|
||||
const menuItems = useComputed(() => {
|
||||
if (!open) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return (action.children as ActionV2Variant[]).map((childAction) =>
|
||||
actionV2ToMenuItem(childAction, actionContext)
|
||||
);
|
||||
}, [open, action.children, actionContext]);
|
||||
|
||||
const handleOpenChange = React.useCallback(
|
||||
(open: boolean) => {
|
||||
if (open) {
|
||||
onOpen?.();
|
||||
} else {
|
||||
onClose?.();
|
||||
}
|
||||
},
|
||||
[onOpen, onClose]
|
||||
);
|
||||
|
||||
const enablePointerEvents = React.useCallback(() => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.style.pointerEvents = "auto";
|
||||
}
|
||||
}, []);
|
||||
|
||||
const disablePointerEvents = React.useCallback(() => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.style.pointerEvents = "none";
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleCloseAutoFocus = React.useCallback(
|
||||
(e: Event) => e.preventDefault(),
|
||||
[]
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
const content = toMenuItems(menuItems);
|
||||
|
||||
return (
|
||||
<MenuProvider variant={"context"}>
|
||||
<Menu onOpenChange={handleOpenChange}>
|
||||
<MenuTrigger aria-label={ariaLabel}>{children}</MenuTrigger>
|
||||
<MenuContent
|
||||
aria-label={ariaLabel}
|
||||
onAnimationStart={disablePointerEvents}
|
||||
onAnimationEnd={enablePointerEvents}
|
||||
onCloseAutoFocus={handleCloseAutoFocus}
|
||||
>
|
||||
{content}
|
||||
</MenuContent>
|
||||
</Menu>
|
||||
</MenuProvider>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -8,11 +8,8 @@ import {
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "~/components/primitives/Drawer";
|
||||
import {
|
||||
DropdownMenu as DropdownMenuRoot,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
} from "~/components/primitives/DropdownMenu";
|
||||
import { Menu, MenuContent, MenuTrigger } from "~/components/primitives/Menu";
|
||||
import { MenuProvider } from "~/components/primitives/Menu/MenuContext";
|
||||
import { actionV2ToMenuItem } from "~/actions";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
@@ -23,7 +20,7 @@ import {
|
||||
MenuItem,
|
||||
MenuItemWithChildren,
|
||||
} from "~/types";
|
||||
import { toDropdownMenuItems, toMobileMenuItems } from "./transformer";
|
||||
import { toMenuItems, toMobileMenuItems } from "./transformer";
|
||||
import { observer } from "mobx-react";
|
||||
import { useComputed } from "~/hooks/useComputed";
|
||||
|
||||
@@ -66,7 +63,7 @@ export const DropdownMenu = observer(
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const isMobile = useMobile();
|
||||
const contentRef =
|
||||
React.useRef<React.ElementRef<typeof DropdownMenuContent>>(null);
|
||||
React.useRef<React.ElementRef<typeof MenuContent>>(null);
|
||||
|
||||
const actionContext =
|
||||
context ??
|
||||
@@ -126,24 +123,26 @@ export const DropdownMenu = observer(
|
||||
);
|
||||
}
|
||||
|
||||
const content = toDropdownMenuItems(menuItems);
|
||||
const content = toMenuItems(menuItems);
|
||||
|
||||
return (
|
||||
<DropdownMenuRoot open={open} onOpenChange={handleOpenChange}>
|
||||
<DropdownMenuTrigger ref={ref} aria-label={ariaLabel} {...rest}>
|
||||
{children}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align={align}
|
||||
aria-label={ariaLabel}
|
||||
onAnimationStart={disablePointerEvents}
|
||||
onAnimationEnd={enablePointerEvents}
|
||||
onCloseAutoFocus={handleCloseAutoFocus}
|
||||
>
|
||||
{content}
|
||||
{append}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuRoot>
|
||||
<MenuProvider variant={"dropdown"}>
|
||||
<Menu open={open} onOpenChange={handleOpenChange}>
|
||||
<MenuTrigger ref={ref} aria-label={ariaLabel} {...rest}>
|
||||
{children}
|
||||
</MenuTrigger>
|
||||
<MenuContent
|
||||
align={align}
|
||||
aria-label={ariaLabel}
|
||||
onAnimationStart={disablePointerEvents}
|
||||
onAnimationEnd={enablePointerEvents}
|
||||
onCloseAutoFocus={handleCloseAutoFocus}
|
||||
>
|
||||
{content}
|
||||
{append}
|
||||
</MenuContent>
|
||||
</Menu>
|
||||
</MenuProvider>
|
||||
);
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,28 +1,18 @@
|
||||
import { CheckmarkIcon } from "outline-icons";
|
||||
import {
|
||||
DropdownMenuButton,
|
||||
DropdownMenuExternalLink,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuInternalLink,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownSubMenu,
|
||||
DropdownSubMenuContent,
|
||||
DropdownSubMenuTrigger,
|
||||
} from "~/components/primitives/DropdownMenu";
|
||||
import {
|
||||
MenuButton,
|
||||
MenuIconWrapper,
|
||||
MenuInternalLink,
|
||||
MenuExternalLink,
|
||||
MenuLabel,
|
||||
MenuSeparator,
|
||||
MenuDisclosure,
|
||||
SelectedIconWrapper,
|
||||
} from "~/components/primitives/components/Menu";
|
||||
SubMenu,
|
||||
SubMenuTrigger,
|
||||
SubMenuContent,
|
||||
MenuGroup,
|
||||
} from "~/components/primitives/Menu";
|
||||
import * as Components from "~/components/primitives/components/Menu";
|
||||
import { MenuItem } from "~/types";
|
||||
|
||||
export function toDropdownMenuItems(items: MenuItem[]) {
|
||||
export function toMenuItems(items: MenuItem[]) {
|
||||
const filteredItems = filterMenuItems(items);
|
||||
|
||||
if (!filteredItems.length) {
|
||||
@@ -39,15 +29,15 @@ export function toDropdownMenuItems(items: MenuItem[]) {
|
||||
|
||||
return filteredItems.map((item, index) => {
|
||||
const icon = showIcon ? (
|
||||
<MenuIconWrapper aria-hidden>
|
||||
<Components.MenuIconWrapper aria-hidden>
|
||||
{"icon" in item ? item.icon : null}
|
||||
</MenuIconWrapper>
|
||||
</Components.MenuIconWrapper>
|
||||
) : undefined;
|
||||
|
||||
switch (item.type) {
|
||||
case "button":
|
||||
return (
|
||||
<DropdownMenuButton
|
||||
<MenuButton
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
label={item.title as string}
|
||||
icon={icon}
|
||||
@@ -61,7 +51,7 @@ export function toDropdownMenuItems(items: MenuItem[]) {
|
||||
|
||||
case "route":
|
||||
return (
|
||||
<DropdownMenuInternalLink
|
||||
<MenuInternalLink
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
label={item.title as string}
|
||||
icon={icon}
|
||||
@@ -72,7 +62,7 @@ export function toDropdownMenuItems(items: MenuItem[]) {
|
||||
|
||||
case "link":
|
||||
return (
|
||||
<DropdownMenuExternalLink
|
||||
<MenuExternalLink
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
label={item.title as string}
|
||||
icon={icon}
|
||||
@@ -85,33 +75,33 @@ export function toDropdownMenuItems(items: MenuItem[]) {
|
||||
);
|
||||
|
||||
case "submenu": {
|
||||
const submenuItems = toDropdownMenuItems(item.items);
|
||||
const submenuItems = toMenuItems(item.items);
|
||||
|
||||
if (!submenuItems?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownSubMenu key={`${item.type}-${item.title}-${index}`}>
|
||||
<DropdownSubMenuTrigger
|
||||
<SubMenu key={`${item.type}-${item.title}-${index}`}>
|
||||
<SubMenuTrigger
|
||||
label={item.title as string}
|
||||
icon={icon}
|
||||
disabled={item.disabled}
|
||||
/>
|
||||
<DropdownSubMenuContent>{submenuItems}</DropdownSubMenuContent>
|
||||
</DropdownSubMenu>
|
||||
<SubMenuContent>{submenuItems}</SubMenuContent>
|
||||
</SubMenu>
|
||||
);
|
||||
}
|
||||
|
||||
case "group": {
|
||||
const groupItems = toDropdownMenuItems(item.items);
|
||||
const groupItems = toMenuItems(item.items);
|
||||
|
||||
if (!groupItems?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuGroup
|
||||
<MenuGroup
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
label={item.title as string}
|
||||
items={groupItems}
|
||||
@@ -120,7 +110,7 @@ export function toDropdownMenuItems(items: MenuItem[]) {
|
||||
}
|
||||
|
||||
case "separator":
|
||||
return <DropdownMenuSeparator key={`${item.type}-${index}`} />;
|
||||
return <MenuSeparator key={`${item.type}-${index}`} />;
|
||||
|
||||
default:
|
||||
return null;
|
||||
@@ -149,15 +139,15 @@ export function toMobileMenuItems(
|
||||
|
||||
return filteredItems.map((item, index) => {
|
||||
const icon = showIcon ? (
|
||||
<MenuIconWrapper aria-hidden>
|
||||
<Components.MenuIconWrapper aria-hidden>
|
||||
{"icon" in item ? item.icon : null}
|
||||
</MenuIconWrapper>
|
||||
</Components.MenuIconWrapper>
|
||||
) : undefined;
|
||||
|
||||
switch (item.type) {
|
||||
case "button":
|
||||
return (
|
||||
<MenuButton
|
||||
<Components.MenuButton
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
disabled={item.disabled}
|
||||
$dangerous={item.dangerous}
|
||||
@@ -167,31 +157,31 @@ export function toMobileMenuItems(
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
<MenuLabel>{item.title}</MenuLabel>
|
||||
<Components.MenuLabel>{item.title}</Components.MenuLabel>
|
||||
{item.selected !== undefined && (
|
||||
<SelectedIconWrapper aria-hidden>
|
||||
<Components.SelectedIconWrapper aria-hidden>
|
||||
{item.selected ? <CheckmarkIcon /> : null}
|
||||
</SelectedIconWrapper>
|
||||
</Components.SelectedIconWrapper>
|
||||
)}
|
||||
</MenuButton>
|
||||
</Components.MenuButton>
|
||||
);
|
||||
|
||||
case "route":
|
||||
return (
|
||||
<MenuInternalLink
|
||||
<Components.MenuInternalLink
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
to={item.to}
|
||||
disabled={item.disabled}
|
||||
onClick={closeMenu}
|
||||
>
|
||||
{icon}
|
||||
<MenuLabel>{item.title}</MenuLabel>
|
||||
</MenuInternalLink>
|
||||
<Components.MenuLabel>{item.title}</Components.MenuLabel>
|
||||
</Components.MenuInternalLink>
|
||||
);
|
||||
|
||||
case "link":
|
||||
return (
|
||||
<MenuExternalLink
|
||||
<Components.MenuExternalLink
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
href={typeof item.href === "string" ? item.href : item.href.url}
|
||||
target={
|
||||
@@ -201,8 +191,8 @@ export function toMobileMenuItems(
|
||||
onClick={closeMenu}
|
||||
>
|
||||
{icon}
|
||||
<MenuLabel>{item.title}</MenuLabel>
|
||||
</MenuExternalLink>
|
||||
<Components.MenuLabel>{item.title}</Components.MenuLabel>
|
||||
</Components.MenuExternalLink>
|
||||
);
|
||||
|
||||
case "submenu": {
|
||||
@@ -217,7 +207,7 @@ export function toMobileMenuItems(
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuButton
|
||||
<Components.MenuButton
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
disabled={item.disabled}
|
||||
onClick={() => {
|
||||
@@ -225,9 +215,9 @@ export function toMobileMenuItems(
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
<MenuLabel>{item.title}</MenuLabel>
|
||||
<MenuDisclosure />
|
||||
</MenuButton>
|
||||
<Components.MenuLabel>{item.title}</Components.MenuLabel>
|
||||
<Components.MenuDisclosure />
|
||||
</Components.MenuButton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -244,14 +234,14 @@ export function toMobileMenuItems(
|
||||
|
||||
return (
|
||||
<div key={`${item.type}-${item.title}-${index}`}>
|
||||
<DropdownMenuLabel>{item.title}</DropdownMenuLabel>
|
||||
<Components.MenuHeader>{item.title}</Components.MenuHeader>
|
||||
{groupItems}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case "separator":
|
||||
return <MenuSeparator key={`${item.type}-${index}`} />;
|
||||
return <Components.MenuSeparator key={`${item.type}-${index}`} />;
|
||||
|
||||
default:
|
||||
return null;
|
||||
|
||||
@@ -1,291 +0,0 @@
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { LocationDescriptor } from "history";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import { fadeAndScaleIn } from "~/styles/animations";
|
||||
import {
|
||||
MenuButton,
|
||||
MenuDisclosure,
|
||||
MenuExternalLink,
|
||||
MenuHeader,
|
||||
MenuInternalLink,
|
||||
MenuLabel,
|
||||
MenuSeparator,
|
||||
MenuSubTrigger,
|
||||
SelectedIconWrapper,
|
||||
} from "./components/Menu";
|
||||
import { CheckmarkIcon } from "outline-icons";
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
|
||||
const DropdownSubMenu = DropdownMenuPrimitive.Sub;
|
||||
|
||||
const DropdownMenuTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Trigger>
|
||||
>((props, ref) => {
|
||||
const { children, ...rest } = props;
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger ref={ref} {...rest} asChild>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.Trigger>
|
||||
);
|
||||
});
|
||||
DropdownMenuTrigger.displayName = DropdownMenuPrimitive.Trigger.displayName;
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>((props, ref) => {
|
||||
const { children, ...rest } = props;
|
||||
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
{...rest}
|
||||
sideOffset={4}
|
||||
collisionPadding={6}
|
||||
asChild
|
||||
>
|
||||
<StyledScrollable hiddenScrollbars>{children}</StyledScrollable>
|
||||
</DropdownMenuPrimitive.Content>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
);
|
||||
});
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
type DropdownSubMenuTriggerProps = BaseDropdownItemProps &
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger>;
|
||||
|
||||
const DropdownSubMenuTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
DropdownSubMenuTriggerProps
|
||||
>((props, ref) => {
|
||||
const { label, icon, disabled, ...rest } = props;
|
||||
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger ref={ref} {...rest} asChild>
|
||||
<MenuSubTrigger disabled={disabled}>
|
||||
{icon}
|
||||
<MenuLabel>{label}</MenuLabel>
|
||||
<MenuDisclosure />
|
||||
</MenuSubTrigger>
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
);
|
||||
});
|
||||
DropdownSubMenuTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const DropdownSubMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>((props, ref) => {
|
||||
const { children, ...rest } = props;
|
||||
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
{...rest}
|
||||
collisionPadding={6}
|
||||
asChild
|
||||
>
|
||||
<StyledScrollable hiddenScrollbars>{children}</StyledScrollable>
|
||||
</DropdownMenuPrimitive.SubContent>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
);
|
||||
});
|
||||
DropdownSubMenuContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName;
|
||||
|
||||
type DropdownMenuGroupProps = {
|
||||
label: string;
|
||||
items: React.ReactNode[];
|
||||
} & Omit<
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Group>,
|
||||
"children" | "asChild"
|
||||
>;
|
||||
|
||||
const DropdownMenuGroup = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Group>,
|
||||
DropdownMenuGroupProps
|
||||
>((props, ref) => {
|
||||
const { label, items, ...rest } = props;
|
||||
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group ref={ref} {...rest}>
|
||||
<DropdownMenuLabel>{label}</DropdownMenuLabel>
|
||||
{items}
|
||||
</DropdownMenuPrimitive.Group>
|
||||
);
|
||||
});
|
||||
DropdownMenuGroup.displayName = DropdownMenuPrimitive.Group.displayName;
|
||||
|
||||
type BaseDropdownItemProps = {
|
||||
label: string;
|
||||
icon?: React.ReactElement;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
type DropdownMenuButtonProps = BaseDropdownItemProps & {
|
||||
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
tooltip?: React.ReactChild;
|
||||
selected?: boolean;
|
||||
dangerous?: boolean;
|
||||
} & Omit<
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>,
|
||||
"children" | "asChild" | "onClick"
|
||||
>;
|
||||
|
||||
const DropdownMenuButton = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
DropdownMenuButtonProps
|
||||
>((props, ref) => {
|
||||
const {
|
||||
label,
|
||||
icon,
|
||||
tooltip,
|
||||
disabled,
|
||||
selected,
|
||||
dangerous,
|
||||
onClick,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const button = (
|
||||
<DropdownMenuPrimitive.Item ref={ref} disabled={disabled} {...rest} asChild>
|
||||
<MenuButton disabled={disabled} $dangerous={dangerous} onClick={onClick}>
|
||||
{icon}
|
||||
<MenuLabel>{label}</MenuLabel>
|
||||
{selected !== undefined && (
|
||||
<SelectedIconWrapper aria-hidden>
|
||||
{selected ? <CheckmarkIcon /> : null}
|
||||
</SelectedIconWrapper>
|
||||
)}
|
||||
</MenuButton>
|
||||
</DropdownMenuPrimitive.Item>
|
||||
);
|
||||
|
||||
return tooltip ? (
|
||||
<Tooltip content={tooltip} placement="bottom">
|
||||
<div>{button}</div>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<>{button}</>
|
||||
);
|
||||
});
|
||||
DropdownMenuButton.displayName = "DropdownMenuButton";
|
||||
|
||||
type DropdownMenuInternalLinkProps = BaseDropdownItemProps & {
|
||||
to: LocationDescriptor;
|
||||
} & Omit<
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>,
|
||||
"children" | "asChild" | "onClick"
|
||||
>;
|
||||
|
||||
const DropdownMenuInternalLink = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
DropdownMenuInternalLinkProps
|
||||
>((props, ref) => {
|
||||
const { label, icon, disabled, to, ...rest } = props;
|
||||
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item ref={ref} disabled={disabled} {...rest} asChild>
|
||||
<MenuInternalLink to={to} disabled={disabled}>
|
||||
{icon}
|
||||
<MenuLabel>{label}</MenuLabel>
|
||||
</MenuInternalLink>
|
||||
</DropdownMenuPrimitive.Item>
|
||||
);
|
||||
});
|
||||
DropdownMenuInternalLink.displayName = "DropdownMenuInternalLink";
|
||||
|
||||
type DropdownMenuExternalLinkProps = BaseDropdownItemProps & {
|
||||
href: string;
|
||||
target?: string;
|
||||
} & Omit<
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>,
|
||||
"children" | "asChild" | "onClick"
|
||||
>;
|
||||
|
||||
const DropdownMenuExternalLink = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
DropdownMenuExternalLinkProps
|
||||
>((props, ref) => {
|
||||
const { label, icon, disabled, href, target, ...rest } = props;
|
||||
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item ref={ref} disabled={disabled} {...rest} asChild>
|
||||
<MenuExternalLink href={href} target={target} disabled={disabled}>
|
||||
{icon}
|
||||
<MenuLabel>{label}</MenuLabel>
|
||||
</MenuExternalLink>
|
||||
</DropdownMenuPrimitive.Item>
|
||||
);
|
||||
});
|
||||
DropdownMenuExternalLink.displayName = "DropdownMenuExternalLink";
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>((props, ref) => (
|
||||
<DropdownMenuPrimitive.Separator ref={ref} {...props} asChild>
|
||||
<MenuSeparator />
|
||||
</DropdownMenuPrimitive.Separator>
|
||||
));
|
||||
DropdownMenuSeparator.displayName = "DropdownMenuSeparator";
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label ref={ref} {...props} asChild>
|
||||
<MenuHeader>{children}</MenuHeader>
|
||||
</DropdownMenuPrimitive.Label>
|
||||
));
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||
|
||||
/** Styled components */
|
||||
const StyledScrollable = styled(Scrollable)`
|
||||
z-index: ${depths.menu};
|
||||
min-width: 180px;
|
||||
max-width: 276px;
|
||||
min-height: 44px;
|
||||
max-height: min(85vh, var(--radix-dropdown-menu-content-available-height));
|
||||
font-weight: normal;
|
||||
|
||||
background: ${s("menuBackground")};
|
||||
box-shadow: ${s("menuShadow")};
|
||||
border-radius: 6px;
|
||||
padding: 6px;
|
||||
outline: none;
|
||||
|
||||
transform-origin: var(--radix-dropdown-menu-content-transform-origin);
|
||||
|
||||
&[data-state="open"] {
|
||||
animation: ${fadeAndScaleIn} 150ms ease-out;
|
||||
}
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuButton,
|
||||
DropdownMenuInternalLink,
|
||||
DropdownMenuExternalLink,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownSubMenu,
|
||||
DropdownSubMenuTrigger,
|
||||
DropdownSubMenuContent,
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { createContext, useContext, useMemo } from "react";
|
||||
|
||||
type MenuVariant = "dropdown" | "context";
|
||||
|
||||
const MenuContext = createContext<{
|
||||
variant: MenuVariant;
|
||||
}>({
|
||||
variant: "dropdown",
|
||||
});
|
||||
|
||||
export function MenuProvider({
|
||||
variant,
|
||||
children,
|
||||
}: {
|
||||
variant: MenuVariant;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const ctx = useMemo(() => ({ variant }), [variant]);
|
||||
|
||||
return <MenuContext.Provider value={ctx}>{children}</MenuContext.Provider>;
|
||||
}
|
||||
|
||||
export const useMenuContext = () => useContext(MenuContext);
|
||||
@@ -0,0 +1,435 @@
|
||||
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import * as Components from "../components/Menu";
|
||||
import { LocationDescriptor } from "history";
|
||||
import * as React from "react";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import { CheckmarkIcon } from "outline-icons";
|
||||
import { useMenuContext } from "./MenuContext";
|
||||
|
||||
type MenuProps = React.ComponentPropsWithoutRef<
|
||||
typeof DropdownMenuPrimitive.Root
|
||||
> &
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Root>;
|
||||
|
||||
const Menu = ({ children, ...rest }: MenuProps) => {
|
||||
const { variant } = useMenuContext();
|
||||
|
||||
const Root =
|
||||
variant === "dropdown"
|
||||
? DropdownMenuPrimitive.Root
|
||||
: ContextMenuPrimitive.Root;
|
||||
|
||||
return <Root {...rest}>{children}</Root>;
|
||||
};
|
||||
|
||||
type SubMenuProps = React.ComponentPropsWithoutRef<
|
||||
typeof DropdownMenuPrimitive.Sub
|
||||
> &
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Sub>;
|
||||
|
||||
const SubMenu = ({ children, ...rest }: SubMenuProps) => {
|
||||
const { variant } = useMenuContext();
|
||||
|
||||
const Sub =
|
||||
variant === "dropdown"
|
||||
? DropdownMenuPrimitive.Sub
|
||||
: ContextMenuPrimitive.Sub;
|
||||
|
||||
return <Sub {...rest}>{children}</Sub>;
|
||||
};
|
||||
|
||||
type TriggerProps = React.ComponentPropsWithoutRef<
|
||||
typeof DropdownMenuPrimitive.Trigger
|
||||
> &
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Trigger>;
|
||||
|
||||
const MenuTrigger = React.forwardRef<
|
||||
| React.ElementRef<typeof DropdownMenuPrimitive.Trigger>
|
||||
| React.ElementRef<typeof ContextMenuPrimitive.Trigger>,
|
||||
TriggerProps
|
||||
>((props, ref) => {
|
||||
const { variant } = useMenuContext();
|
||||
const { children, ...rest } = props;
|
||||
|
||||
const Trigger =
|
||||
variant === "dropdown"
|
||||
? DropdownMenuPrimitive.Trigger
|
||||
: ContextMenuPrimitive.Trigger;
|
||||
|
||||
return (
|
||||
<Trigger ref={ref} {...rest} asChild>
|
||||
{children}
|
||||
</Trigger>
|
||||
);
|
||||
});
|
||||
MenuTrigger.displayName = "MenuTrigger";
|
||||
|
||||
type ContentProps = React.ComponentPropsWithoutRef<
|
||||
typeof DropdownMenuPrimitive.Content
|
||||
> &
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>;
|
||||
|
||||
const MenuContent = React.forwardRef<
|
||||
| React.ElementRef<typeof DropdownMenuPrimitive.Content>
|
||||
| React.ElementRef<typeof ContextMenuPrimitive.Content>,
|
||||
ContentProps
|
||||
>((props, ref) => {
|
||||
const { variant } = useMenuContext();
|
||||
const { children, ...rest } = props;
|
||||
|
||||
const Portal =
|
||||
variant === "dropdown"
|
||||
? DropdownMenuPrimitive.Portal
|
||||
: ContextMenuPrimitive.Portal;
|
||||
|
||||
const Content =
|
||||
variant === "dropdown"
|
||||
? DropdownMenuPrimitive.Content
|
||||
: ContextMenuPrimitive.Content;
|
||||
|
||||
const offsetProp =
|
||||
variant === "dropdown" ? { sideOffset: 4 } : { alignOffset: 4 };
|
||||
|
||||
const contentProps = {
|
||||
maxHeightVar:
|
||||
variant === "dropdown"
|
||||
? "--radix-dropdown-menu-content-available-height"
|
||||
: "--radix-context-menu-content-available-height",
|
||||
transformOriginVar:
|
||||
variant === "dropdown"
|
||||
? "--radix-dropdown-menu-content-transform-origin"
|
||||
: "--radix-context-menu-content-transform-origin",
|
||||
};
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Content ref={ref} {...rest} {...offsetProp} collisionPadding={6} asChild>
|
||||
<Components.MenuContent {...contentProps} hiddenScrollbars>
|
||||
{children}
|
||||
</Components.MenuContent>
|
||||
</Content>
|
||||
</Portal>
|
||||
);
|
||||
});
|
||||
MenuContent.displayName = "MenuContent";
|
||||
|
||||
type SubMenuTriggerProps = BaseItemProps &
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> &
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger>;
|
||||
|
||||
const SubMenuTrigger = React.forwardRef<
|
||||
| React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>
|
||||
| React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
|
||||
SubMenuTriggerProps
|
||||
>((props, ref) => {
|
||||
const { variant } = useMenuContext();
|
||||
const { label, icon, disabled, ...rest } = props;
|
||||
|
||||
const Trigger =
|
||||
variant === "dropdown"
|
||||
? DropdownMenuPrimitive.SubTrigger
|
||||
: ContextMenuPrimitive.SubTrigger;
|
||||
|
||||
return (
|
||||
<Trigger ref={ref} {...rest} asChild>
|
||||
<Components.MenuSubTrigger disabled={disabled}>
|
||||
{icon}
|
||||
<Components.MenuLabel>{label}</Components.MenuLabel>
|
||||
<Components.MenuDisclosure />
|
||||
</Components.MenuSubTrigger>
|
||||
</Trigger>
|
||||
);
|
||||
});
|
||||
SubMenuTrigger.displayName = "SubMenuTrigger";
|
||||
|
||||
type SubMenuContentProps = React.ComponentPropsWithoutRef<
|
||||
typeof DropdownMenuPrimitive.SubContent
|
||||
> &
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>;
|
||||
|
||||
const SubMenuContent = React.forwardRef<
|
||||
| React.ElementRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
| React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
|
||||
SubMenuContentProps
|
||||
>((props, ref) => {
|
||||
const { variant } = useMenuContext();
|
||||
const { children, ...rest } = props;
|
||||
|
||||
const Portal =
|
||||
variant === "dropdown"
|
||||
? DropdownMenuPrimitive.Portal
|
||||
: ContextMenuPrimitive.Portal;
|
||||
|
||||
const Content =
|
||||
variant === "dropdown"
|
||||
? DropdownMenuPrimitive.SubContent
|
||||
: ContextMenuPrimitive.SubContent;
|
||||
|
||||
const contentProps = {
|
||||
maxHeightVar:
|
||||
variant === "dropdown"
|
||||
? "--radix-dropdown-menu-content-available-height"
|
||||
: "--radix-context-menu-content-available-height",
|
||||
transformOriginVar:
|
||||
variant === "dropdown"
|
||||
? "--radix-dropdown-menu-content-transform-origin"
|
||||
: "--radix-context-menu-content-transform-origin",
|
||||
};
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Content ref={ref} {...rest} collisionPadding={6} asChild>
|
||||
<Components.MenuContent {...contentProps} hiddenScrollbars>
|
||||
{children}
|
||||
</Components.MenuContent>
|
||||
</Content>
|
||||
</Portal>
|
||||
);
|
||||
});
|
||||
SubMenuContent.displayName = "SubMenuContent";
|
||||
|
||||
type MenuGroupProps = {
|
||||
label: string;
|
||||
items: React.ReactNode[];
|
||||
} & Omit<
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Group>,
|
||||
"children" | "asChild"
|
||||
> &
|
||||
Omit<
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Group>,
|
||||
"children" | "asChild"
|
||||
>;
|
||||
|
||||
const MenuGroup = React.forwardRef<
|
||||
| React.ElementRef<typeof DropdownMenuPrimitive.Group>
|
||||
| React.ElementRef<typeof ContextMenuPrimitive.Group>,
|
||||
MenuGroupProps
|
||||
>((props, ref) => {
|
||||
const { variant } = useMenuContext();
|
||||
const { label, items, ...rest } = props;
|
||||
|
||||
const Group =
|
||||
variant === "dropdown"
|
||||
? DropdownMenuPrimitive.Group
|
||||
: ContextMenuPrimitive.Group;
|
||||
|
||||
return (
|
||||
<Group ref={ref} {...rest}>
|
||||
<MenuLabel>{label}</MenuLabel>
|
||||
{items}
|
||||
</Group>
|
||||
);
|
||||
});
|
||||
MenuGroup.displayName = "MenuGroup";
|
||||
|
||||
type BaseItemProps = {
|
||||
label: string;
|
||||
icon?: React.ReactElement;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
type MenuButtonProps = BaseItemProps & {
|
||||
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
tooltip?: React.ReactChild;
|
||||
selected?: boolean;
|
||||
dangerous?: boolean;
|
||||
} & Omit<
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>,
|
||||
"children" | "asChild" | "onClick"
|
||||
> &
|
||||
Omit<
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item>,
|
||||
"children" | "asChild" | "onClick"
|
||||
>;
|
||||
|
||||
const MenuButton = React.forwardRef<
|
||||
| React.ElementRef<typeof DropdownMenuPrimitive.Item>
|
||||
| React.ElementRef<typeof ContextMenuPrimitive.Item>,
|
||||
MenuButtonProps
|
||||
>((props, ref) => {
|
||||
const { variant } = useMenuContext();
|
||||
const {
|
||||
label,
|
||||
icon,
|
||||
tooltip,
|
||||
disabled,
|
||||
selected,
|
||||
dangerous,
|
||||
onClick,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const Item =
|
||||
variant === "dropdown"
|
||||
? DropdownMenuPrimitive.Item
|
||||
: ContextMenuPrimitive.Item;
|
||||
|
||||
const button = (
|
||||
<Item ref={ref} disabled={disabled} {...rest} asChild>
|
||||
<Components.MenuButton
|
||||
disabled={disabled}
|
||||
$dangerous={dangerous}
|
||||
onClick={onClick}
|
||||
>
|
||||
{icon}
|
||||
<Components.MenuLabel>{label}</Components.MenuLabel>
|
||||
{selected !== undefined && (
|
||||
<Components.SelectedIconWrapper aria-hidden>
|
||||
{selected ? <CheckmarkIcon /> : null}
|
||||
</Components.SelectedIconWrapper>
|
||||
)}
|
||||
</Components.MenuButton>
|
||||
</Item>
|
||||
);
|
||||
|
||||
return tooltip ? (
|
||||
<Tooltip content={tooltip} placement="bottom">
|
||||
<div>{button}</div>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<>{button}</>
|
||||
);
|
||||
});
|
||||
MenuButton.displayName = "MenuButton";
|
||||
|
||||
type MenuInternalLinkProps = BaseItemProps & {
|
||||
to: LocationDescriptor;
|
||||
} & Omit<
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>,
|
||||
"children" | "asChild" | "onClick"
|
||||
> &
|
||||
Omit<
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item>,
|
||||
"children" | "asChild" | "onClick"
|
||||
>;
|
||||
|
||||
const MenuInternalLink = React.forwardRef<
|
||||
| React.ElementRef<typeof DropdownMenuPrimitive.Item>
|
||||
| React.ElementRef<typeof ContextMenuPrimitive.Item>,
|
||||
MenuInternalLinkProps
|
||||
>((props, ref) => {
|
||||
const { variant } = useMenuContext();
|
||||
const { label, icon, disabled, to, ...rest } = props;
|
||||
|
||||
const Item =
|
||||
variant === "dropdown"
|
||||
? DropdownMenuPrimitive.Item
|
||||
: ContextMenuPrimitive.Item;
|
||||
|
||||
return (
|
||||
<Item ref={ref} disabled={disabled} {...rest} asChild>
|
||||
<Components.MenuInternalLink to={to} disabled={disabled}>
|
||||
{icon}
|
||||
<Components.MenuLabel>{label}</Components.MenuLabel>
|
||||
</Components.MenuInternalLink>
|
||||
</Item>
|
||||
);
|
||||
});
|
||||
MenuInternalLink.displayName = "MenuInternalLink";
|
||||
|
||||
type MenuExternalLinkProps = BaseItemProps & {
|
||||
href: string;
|
||||
target?: string;
|
||||
} & Omit<
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>,
|
||||
"children" | "asChild" | "onClick"
|
||||
> &
|
||||
Omit<
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item>,
|
||||
"children" | "asChild" | "onClick"
|
||||
>;
|
||||
|
||||
const MenuExternalLink = React.forwardRef<
|
||||
| React.ElementRef<typeof DropdownMenuPrimitive.Item>
|
||||
| React.ElementRef<typeof ContextMenuPrimitive.Item>,
|
||||
MenuExternalLinkProps
|
||||
>((props, ref) => {
|
||||
const { variant } = useMenuContext();
|
||||
const { label, icon, disabled, href, target, ...rest } = props;
|
||||
|
||||
const Item =
|
||||
variant === "dropdown"
|
||||
? DropdownMenuPrimitive.Item
|
||||
: ContextMenuPrimitive.Item;
|
||||
|
||||
return (
|
||||
<Item ref={ref} disabled={disabled} {...rest} asChild>
|
||||
<Components.MenuExternalLink
|
||||
href={href}
|
||||
target={target}
|
||||
disabled={disabled}
|
||||
>
|
||||
{icon}
|
||||
<Components.MenuLabel>{label}</Components.MenuLabel>
|
||||
</Components.MenuExternalLink>
|
||||
</Item>
|
||||
);
|
||||
});
|
||||
MenuExternalLink.displayName = "MenuExternalLink";
|
||||
|
||||
type MenuSeparatorProps = React.ComponentPropsWithoutRef<
|
||||
typeof DropdownMenuPrimitive.Separator
|
||||
> &
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>;
|
||||
|
||||
const MenuSeparator = React.forwardRef<
|
||||
| React.ElementRef<typeof DropdownMenuPrimitive.Separator>
|
||||
| React.ElementRef<typeof ContextMenuPrimitive.Separator>,
|
||||
MenuSeparatorProps
|
||||
>((props, ref) => {
|
||||
const { variant } = useMenuContext();
|
||||
|
||||
const Separator =
|
||||
variant === "dropdown"
|
||||
? DropdownMenuPrimitive.Separator
|
||||
: ContextMenuPrimitive.Separator;
|
||||
|
||||
return (
|
||||
<Separator ref={ref} {...props} asChild>
|
||||
<Components.MenuSeparator />
|
||||
</Separator>
|
||||
);
|
||||
});
|
||||
MenuSeparator.displayName = "MenuSeparator";
|
||||
|
||||
type MenuLabelProps = React.ComponentPropsWithoutRef<
|
||||
typeof DropdownMenuPrimitive.Label
|
||||
> &
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label>;
|
||||
|
||||
const MenuLabel = React.forwardRef<
|
||||
| React.ElementRef<typeof DropdownMenuPrimitive.Label>
|
||||
| React.ElementRef<typeof ContextMenuPrimitive.Label>,
|
||||
MenuLabelProps
|
||||
>((props, ref) => {
|
||||
const { variant } = useMenuContext();
|
||||
const { children, ...rest } = props;
|
||||
|
||||
const Label =
|
||||
variant === "dropdown"
|
||||
? DropdownMenuPrimitive.Label
|
||||
: ContextMenuPrimitive.Label;
|
||||
|
||||
return (
|
||||
<Label ref={ref} {...rest} asChild>
|
||||
<Components.MenuHeader>{children}</Components.MenuHeader>
|
||||
</Label>
|
||||
);
|
||||
});
|
||||
MenuLabel.displayName = "MenuLabel";
|
||||
|
||||
export {
|
||||
Menu,
|
||||
MenuTrigger,
|
||||
MenuContent,
|
||||
MenuButton,
|
||||
MenuInternalLink,
|
||||
MenuExternalLink,
|
||||
MenuSeparator,
|
||||
MenuGroup,
|
||||
MenuLabel,
|
||||
SubMenu,
|
||||
SubMenuTrigger,
|
||||
SubMenuContent,
|
||||
};
|
||||
@@ -3,7 +3,9 @@ import { ellipsis } from "polished";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled, { css } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { s } from "@shared/styles";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import { fadeAndScaleIn } from "~/styles/animations";
|
||||
|
||||
type BaseMenuItemProps = {
|
||||
disabled?: boolean;
|
||||
@@ -135,3 +137,31 @@ export const SelectedIconWrapper = styled.span`
|
||||
color: ${s("textSecondary")};
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
export const MenuContent = styled(Scrollable)<{
|
||||
maxHeightVar: string;
|
||||
transformOriginVar: string;
|
||||
}>`
|
||||
z-index: ${depths.menu};
|
||||
min-width: 180px;
|
||||
max-width: 276px;
|
||||
min-height: 44px;
|
||||
max-height: ${({ maxHeightVar }) => `min(85vh, var(${maxHeightVar}))`};
|
||||
font-weight: normal;
|
||||
|
||||
background: ${s("menuBackground")};
|
||||
box-shadow: ${s("menuShadow")};
|
||||
border-radius: 6px;
|
||||
padding: 6px;
|
||||
outline: none;
|
||||
|
||||
transform-origin: ${({ transformOriginVar }) => `var(${transformOriginVar})`};
|
||||
|
||||
&[data-state="open"] {
|
||||
animation: ${fadeAndScaleIn} 150ms ease-out;
|
||||
}
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { InputIcon, SearchIcon } from "outline-icons";
|
||||
import { ActionV2Separator, createActionV2 } from "~/actions";
|
||||
import {
|
||||
restoreDocument,
|
||||
unsubscribeDocument,
|
||||
subscribeDocument,
|
||||
restoreDocumentToCollection,
|
||||
starDocument,
|
||||
unstarDocument,
|
||||
editDocument,
|
||||
shareDocument,
|
||||
createNestedDocument,
|
||||
importDocument,
|
||||
createTemplateFromDocument,
|
||||
duplicateDocument,
|
||||
publishDocument,
|
||||
unpublishDocument,
|
||||
archiveDocument,
|
||||
moveDocument,
|
||||
moveTemplate,
|
||||
applyTemplateFactory,
|
||||
pinDocument,
|
||||
createDocumentFromTemplate,
|
||||
openDocumentComments,
|
||||
openDocumentHistory,
|
||||
openDocumentInsights,
|
||||
downloadDocument,
|
||||
copyDocument,
|
||||
printDocument,
|
||||
searchInDocument,
|
||||
deleteDocument,
|
||||
leaveDocument,
|
||||
permanentlyDeleteDocument,
|
||||
} from "~/actions/definitions/documents";
|
||||
import { ActiveDocumentSection } from "~/actions/sections";
|
||||
import useMobile from "./useMobile";
|
||||
import Document from "~/models/Document";
|
||||
import usePolicy from "./usePolicy";
|
||||
import useCurrentUser from "./useCurrentUser";
|
||||
import { useTemplateMenuActions } from "./useTemplateMenuActions";
|
||||
import { useMenuAction } from "./useMenuAction";
|
||||
|
||||
type Props = {
|
||||
/** Document for which the actions are generated */
|
||||
document: Document;
|
||||
/** Invoked when the "Find and replace" menu item is clicked */
|
||||
onFindAndReplace?: () => void;
|
||||
/** Invoked when the "Rename" menu item is clicked */
|
||||
onRename?: () => void;
|
||||
/** Callback when a template is selected to apply its content to the document */
|
||||
onSelectTemplate?: (template: Document) => void;
|
||||
};
|
||||
|
||||
export function useDocumentMenuAction({
|
||||
document,
|
||||
onFindAndReplace,
|
||||
onRename,
|
||||
onSelectTemplate,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useMobile();
|
||||
const user = useCurrentUser();
|
||||
|
||||
const can = usePolicy(document);
|
||||
|
||||
const templateMenuActions = useTemplateMenuActions({
|
||||
document,
|
||||
onSelectTemplate,
|
||||
});
|
||||
|
||||
const actions = useMemo(
|
||||
() => [
|
||||
restoreDocument,
|
||||
restoreDocumentToCollection,
|
||||
starDocument,
|
||||
unstarDocument,
|
||||
subscribeDocument,
|
||||
unsubscribeDocument,
|
||||
createActionV2({
|
||||
name: `${t("Find and replace")}…`,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <SearchIcon />,
|
||||
visible: !!onFindAndReplace && isMobile,
|
||||
perform: () => onFindAndReplace?.(),
|
||||
}),
|
||||
ActionV2Separator,
|
||||
editDocument,
|
||||
createActionV2({
|
||||
name: `${t("Rename")}…`,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <InputIcon />,
|
||||
visible: !!can.update && !user.separateEditMode && !!onRename,
|
||||
perform: () => requestAnimationFrame(() => onRename?.()),
|
||||
}),
|
||||
shareDocument,
|
||||
createNestedDocument,
|
||||
importDocument,
|
||||
createTemplateFromDocument,
|
||||
duplicateDocument,
|
||||
publishDocument,
|
||||
unpublishDocument,
|
||||
archiveDocument,
|
||||
moveDocument,
|
||||
moveTemplate,
|
||||
applyTemplateFactory({ actions: templateMenuActions }),
|
||||
pinDocument,
|
||||
createDocumentFromTemplate,
|
||||
ActionV2Separator,
|
||||
openDocumentComments,
|
||||
openDocumentHistory,
|
||||
openDocumentInsights,
|
||||
downloadDocument,
|
||||
copyDocument,
|
||||
printDocument,
|
||||
searchInDocument,
|
||||
ActionV2Separator,
|
||||
deleteDocument,
|
||||
permanentlyDeleteDocument,
|
||||
leaveDocument,
|
||||
],
|
||||
[
|
||||
t,
|
||||
isMobile,
|
||||
templateMenuActions,
|
||||
can.update,
|
||||
user.separateEditMode,
|
||||
onFindAndReplace,
|
||||
onRename,
|
||||
]
|
||||
);
|
||||
|
||||
return useMenuAction(actions);
|
||||
}
|
||||
+6
-101
@@ -1,6 +1,5 @@
|
||||
import noop from "lodash/noop";
|
||||
import { observer } from "mobx-react";
|
||||
import { InputIcon, SearchIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
@@ -11,49 +10,14 @@ import Document from "~/models/Document";
|
||||
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
|
||||
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
|
||||
import Switch from "~/components/Switch";
|
||||
import { ActionV2Separator, createActionV2 } from "~/actions";
|
||||
import {
|
||||
pinDocument,
|
||||
createTemplateFromDocument,
|
||||
subscribeDocument,
|
||||
unsubscribeDocument,
|
||||
moveDocument,
|
||||
deleteDocument,
|
||||
permanentlyDeleteDocument,
|
||||
downloadDocument,
|
||||
importDocument,
|
||||
starDocument,
|
||||
unstarDocument,
|
||||
duplicateDocument,
|
||||
archiveDocument,
|
||||
openDocumentHistory,
|
||||
openDocumentInsights,
|
||||
publishDocument,
|
||||
unpublishDocument,
|
||||
printDocument,
|
||||
openDocumentComments,
|
||||
createDocumentFromTemplate,
|
||||
createNestedDocument,
|
||||
shareDocument,
|
||||
copyDocument,
|
||||
searchInDocument,
|
||||
leaveDocument,
|
||||
moveTemplate,
|
||||
restoreDocument,
|
||||
restoreDocumentToCollection,
|
||||
editDocument,
|
||||
applyTemplateFactory,
|
||||
} from "~/actions/definitions/documents";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useRequest from "~/hooks/useRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { ActiveDocumentSection } from "~/actions/sections";
|
||||
import { useTemplateMenuActions } from "~/hooks/useTemplateMenuActions";
|
||||
import { useMenuAction } from "~/hooks/useMenuAction";
|
||||
import { MenuSeparator } from "~/components/primitives/components/Menu";
|
||||
import { useDocumentMenuAction } from "~/hooks/useDocumentMenuAction";
|
||||
|
||||
type Props = {
|
||||
/** Document for which the menu is to be shown */
|
||||
@@ -161,74 +125,13 @@ function DocumentMenu({
|
||||
[document]
|
||||
);
|
||||
|
||||
const templateMenuActions = useTemplateMenuActions({
|
||||
const rootAction = useDocumentMenuAction({
|
||||
document,
|
||||
onFindAndReplace,
|
||||
onRename,
|
||||
onSelectTemplate,
|
||||
});
|
||||
|
||||
const actions = React.useMemo(
|
||||
() => [
|
||||
restoreDocument,
|
||||
restoreDocumentToCollection,
|
||||
starDocument,
|
||||
unstarDocument,
|
||||
subscribeDocument,
|
||||
unsubscribeDocument,
|
||||
createActionV2({
|
||||
name: `${t("Find and replace")}…`,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <SearchIcon />,
|
||||
visible: !!onFindAndReplace && isMobile,
|
||||
perform: () => onFindAndReplace?.(),
|
||||
}),
|
||||
ActionV2Separator,
|
||||
editDocument,
|
||||
createActionV2({
|
||||
name: `${t("Rename")}…`,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <InputIcon />,
|
||||
visible: !!can.update && !user.separateEditMode && !!onRename,
|
||||
perform: () => requestAnimationFrame(() => onRename?.()),
|
||||
}),
|
||||
shareDocument,
|
||||
createNestedDocument,
|
||||
importDocument,
|
||||
createTemplateFromDocument,
|
||||
duplicateDocument,
|
||||
publishDocument,
|
||||
unpublishDocument,
|
||||
archiveDocument,
|
||||
moveDocument,
|
||||
moveTemplate,
|
||||
applyTemplateFactory({ actions: templateMenuActions }),
|
||||
pinDocument,
|
||||
createDocumentFromTemplate,
|
||||
ActionV2Separator,
|
||||
openDocumentComments,
|
||||
openDocumentHistory,
|
||||
openDocumentInsights,
|
||||
downloadDocument,
|
||||
copyDocument,
|
||||
printDocument,
|
||||
searchInDocument,
|
||||
ActionV2Separator,
|
||||
deleteDocument,
|
||||
permanentlyDeleteDocument,
|
||||
leaveDocument,
|
||||
],
|
||||
[
|
||||
t,
|
||||
isMobile,
|
||||
templateMenuActions,
|
||||
can.update,
|
||||
user.separateEditMode,
|
||||
onFindAndReplace,
|
||||
onRename,
|
||||
]
|
||||
);
|
||||
|
||||
const rootAction = useMenuAction(actions);
|
||||
|
||||
const context = useActionContext({
|
||||
isContextMenu: true,
|
||||
activeDocumentId: document.id,
|
||||
@@ -287,6 +190,7 @@ function DocumentMenu({
|
||||
}, [
|
||||
t,
|
||||
can.update,
|
||||
can.updateInsights,
|
||||
document.embedsDisabled,
|
||||
document.fullWidth,
|
||||
document.insightsEnabled,
|
||||
@@ -295,6 +199,7 @@ function DocumentMenu({
|
||||
showToggleEmbeds,
|
||||
handleEmbedsToggle,
|
||||
handleFullWidthToggle,
|
||||
handleInsightsToggle,
|
||||
]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -92,6 +92,7 @@
|
||||
"@outlinewiki/koa-passport": "^4.2.1",
|
||||
"@outlinewiki/passport-azure-ad-oauth2": "^0.1.0",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-one-time-password-field": "^0.1.8",
|
||||
|
||||
@@ -224,6 +224,7 @@
|
||||
"Copy to <em>{{ location }}</em>": "Copy to <em>{{ location }}</em>",
|
||||
"Search collections & documents": "Search collections & documents",
|
||||
"No results found": "No results found",
|
||||
"Document options": "Document options",
|
||||
"New": "New",
|
||||
"Only visible to you": "Only visible to you",
|
||||
"Draft": "Draft",
|
||||
@@ -541,6 +542,7 @@
|
||||
"Video": "Video",
|
||||
"None": "None",
|
||||
"Delete embed": "Delete embed",
|
||||
"Rename": "Rename",
|
||||
"Could not import file": "Could not import file",
|
||||
"Unsubscribed from document": "Unsubscribed from document",
|
||||
"Unsubscribed from collection": "Unsubscribed from collection",
|
||||
@@ -562,12 +564,10 @@
|
||||
"A-Z sort": "A-Z sort",
|
||||
"Z-A sort": "Z-A sort",
|
||||
"Manual sort": "Manual sort",
|
||||
"Rename": "Rename",
|
||||
"Collection menu": "Collection menu",
|
||||
"Comment options": "Comment options",
|
||||
"Enable viewer insights": "Enable viewer insights",
|
||||
"Enable embeds": "Enable embeds",
|
||||
"Document options": "Document options",
|
||||
"File": "File",
|
||||
"Group member options": "Group member options",
|
||||
"Group members": "Group members",
|
||||
|
||||
@@ -3225,6 +3225,18 @@
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz#a2c4c47af6337048ee78ff6dc0d090b390d2bb30"
|
||||
integrity sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==
|
||||
|
||||
"@radix-ui/react-context-menu@^2.2.16":
|
||||
version "2.2.16"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-context-menu/-/react-context-menu-2.2.16.tgz#e7bf94a457b68af08f24ad696949144530faab50"
|
||||
integrity sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==
|
||||
dependencies:
|
||||
"@radix-ui/primitive" "1.1.3"
|
||||
"@radix-ui/react-context" "1.1.2"
|
||||
"@radix-ui/react-menu" "2.1.16"
|
||||
"@radix-ui/react-primitive" "2.1.3"
|
||||
"@radix-ui/react-use-callback-ref" "1.1.1"
|
||||
"@radix-ui/react-use-controllable-state" "1.2.2"
|
||||
|
||||
"@radix-ui/react-context@1.1.2":
|
||||
version "1.1.2"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-context/-/react-context-1.1.2.tgz#61628ef269a433382c364f6f1e3788a6dc213a36"
|
||||
|
||||
Reference in New Issue
Block a user