chore: Move more dropdowns to Radix (part 2) (#9880)

* revision menu

* new child document menu; dropdown trigger now supports tooltip

* import menu

* oauth authentication

* oauth client

* tsc
This commit is contained in:
Hemachandar
2025-08-09 23:48:19 +05:30
committed by GitHub
parent 4822261187
commit 5c070d0428
9 changed files with 271 additions and 271 deletions
+3 -3
View File
@@ -3,7 +3,7 @@ import { LinkIcon, RestoreIcon, TrashIcon } from "outline-icons";
import { matchPath } from "react-router-dom";
import { toast } from "sonner";
import stores from "~/stores";
import { createAction } from "~/actions";
import { createAction, createActionV2 } from "~/actions";
import { RevisionSection } from "~/actions/sections";
import history from "~/utils/history";
import {
@@ -11,7 +11,7 @@ import {
matchDocumentHistory,
} from "~/utils/routeHelpers";
export const restoreRevision = createAction({
export const restoreRevision = createActionV2({
name: ({ t }) => t("Restore"),
analyticsName: "Restore revision",
icon: <RestoreIcon />,
@@ -73,7 +73,7 @@ export const deleteRevision = createAction({
},
});
export const copyLinkToRevision = createAction({
export const copyLinkToRevision = createActionV2({
name: ({ t }) => t("Copy link"),
analyticsName: "Copy link to revision",
icon: <LinkIcon />,
+97 -87
View File
@@ -1,4 +1,5 @@
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import styled from "styled-components";
import Scrollable from "~/components/Scrollable";
import {
@@ -43,101 +44,110 @@ type Props = {
onOpen?: () => void;
/** Callback when menu is closed */
onClose?: () => void;
};
// TODO: Invert the dependency chain by forwarding dropdown ref and props to Tooltip component
} & React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Trigger>;
export const DropdownMenu = observer(function DropdownMenu({
action,
context,
children,
align = "start",
ariaLabel,
onOpen,
onClose,
append,
}: Props) {
const [open, setOpen] = React.useState(false);
const isMobile = useMobile();
const contentRef =
React.useRef<React.ElementRef<typeof DropdownMenuContent>>(null);
export const DropdownMenu = observer(
React.forwardRef<React.ElementRef<typeof TooltipPrimitive.Trigger>, Props>(
(
{
action,
context,
children,
align = "start",
ariaLabel,
append,
onOpen,
onClose,
...rest
},
ref
) => {
const [open, setOpen] = React.useState(false);
const isMobile = useMobile();
const contentRef =
React.useRef<React.ElementRef<typeof DropdownMenuContent>>(null);
const actionContext =
context ??
useActionContext({
isContextMenu: true,
});
const actionContext =
context ??
useActionContext({
isContextMenu: true,
});
const menuItems = useComputed(() => {
if (!open) {
return [];
}
const menuItems = useComputed(() => {
if (!open) {
return [];
}
return (action.children as ActionV2Variant[]).map((childAction) =>
actionV2ToMenuItem(childAction, actionContext)
);
}, [open, action.children, actionContext]);
return (action.children as ActionV2Variant[]).map((childAction) =>
actionV2ToMenuItem(childAction, actionContext)
);
}, [open, action.children, actionContext]);
const handleOpenChange = React.useCallback(
(open: boolean) => {
setOpen(open);
if (open) {
onOpen?.();
} else {
onClose?.();
const handleOpenChange = React.useCallback(
(open: boolean) => {
setOpen(open);
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 (
<MobileDropdown
open={open}
onOpenChange={handleOpenChange}
items={menuItems}
trigger={children}
ariaLabel={ariaLabel}
append={append}
/>
);
}
},
[onOpen, onClose]
);
const enablePointerEvents = React.useCallback(() => {
if (contentRef.current) {
contentRef.current.style.pointerEvents = "auto";
const content = toDropdownMenuItems(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>
);
}
}, []);
const disablePointerEvents = React.useCallback(() => {
if (contentRef.current) {
contentRef.current.style.pointerEvents = "none";
}
}, []);
const handleCloseAutoFocus = React.useCallback(
(e: Event) => e.preventDefault(),
[]
);
if (isMobile) {
return (
<MobileDropdown
open={open}
onOpenChange={handleOpenChange}
items={menuItems}
trigger={children}
ariaLabel={ariaLabel}
append={append}
/>
);
}
const content = toDropdownMenuItems(menuItems);
return (
<DropdownMenuRoot open={open} onOpenChange={handleOpenChange}>
<DropdownMenuTrigger aria-label={ariaLabel}>
{children}
</DropdownMenuTrigger>
<DropdownMenuContent
align={align}
aria-label={ariaLabel}
onAnimationStart={disablePointerEvents}
onAnimationEnd={enablePointerEvents}
onCloseAutoFocus={handleCloseAutoFocus}
>
{content}
{append}
</DropdownMenuContent>
</DropdownMenuRoot>
);
});
)
);
type MobileDropdownProps = {
open: boolean;
+31 -35
View File
@@ -3,12 +3,13 @@ import { CrossIcon, TrashIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import Import from "~/models/Import";
import ContextMenu from "~/components/ContextMenu";
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
import Template from "~/components/ContextMenu/Template";
import { useMenuState } from "~/hooks/useMenuState";
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
import usePolicy from "~/hooks/usePolicy";
import { MenuItem } from "~/types";
import { createActionV2 } from "~/actions";
import { useMenuAction } from "~/hooks/useMenuAction";
const Section = "Imports";
type Props = {
/** Import to which actions will be applied. */
@@ -23,40 +24,35 @@ export const ImportMenu = observer(
({ importModel, onCancel, onDelete }: Props) => {
const { t } = useTranslation();
const can = usePolicy(importModel);
const menu = useMenuState({
modal: true,
});
const items = React.useMemo(
() =>
[
{
type: "button",
title: t("Cancel"),
visible: can.cancel,
icon: <CrossIcon />,
dangerous: true,
onClick: onCancel,
},
{
type: "button",
title: t("Delete"),
visible: can.delete,
icon: <TrashIcon />,
dangerous: true,
onClick: onDelete,
},
] satisfies MenuItem[],
[t, can.delete, can.cancel, onCancel, onDelete]
const actions = React.useMemo(
() => [
createActionV2({
name: t("Cancel"),
section: Section,
visible: !!can.cancel,
icon: <CrossIcon />,
dangerous: true,
perform: onCancel,
}),
createActionV2({
name: t("Delete"),
section: Section,
visible: !!can.delete,
icon: <TrashIcon />,
dangerous: true,
perform: onDelete,
}),
],
[t, can.cancel, can.delete, onCancel, onDelete]
);
const rootAction = useMenuAction(actions);
return (
<>
<OverflowMenuButton aria-label={t("Show menu")} {...menu} />
<ContextMenu {...menu} aria-label={t("Import menu options")}>
<Template {...menu} items={items} />
</ContextMenu>
</>
<DropdownMenu action={rootAction} ariaLabel={t("Import menu options")}>
<OverflowMenuButton />
</DropdownMenu>
);
}
);
+58 -48
View File
@@ -1,40 +1,36 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { MenuButton, MenuButtonHTMLProps } from "reakit/Menu";
import Document from "~/models/Document";
import ContextMenu from "~/components/ContextMenu";
import Template from "~/components/ContextMenu/Template";
import { useMenuState } from "~/hooks/useMenuState";
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { MenuItem } from "~/types";
import { newDocumentPath, newNestedDocumentPath } from "~/utils/routeHelpers";
import { createInternalLinkActionV2 } from "~/actions";
import { ActiveDocumentSection } from "~/actions/sections";
import { useMenuAction } from "~/hooks/useMenuAction";
import Tooltip from "~/components/Tooltip";
import Button from "~/components/Button";
import { PlusIcon } from "outline-icons";
type Props = {
label?: (props: MenuButtonHTMLProps) => React.ReactNode;
document: Document;
};
function NewChildDocumentMenu({ document, label }: Props) {
const menu = useMenuState({
modal: true,
});
function NewChildDocumentMenu({ document }: Props) {
const { t } = useTranslation();
const canCollection = usePolicy(document.collectionId);
const { collections } = useStores();
const items: MenuItem[] = [];
const collection = document.collectionId
? collections.get(document.collectionId)
: undefined;
const collectionName = collection ? collection.name : t("collection");
if (canCollection.createDocument) {
const collection = document.collectionId
? collections.get(document.collectionId)
: undefined;
const collectionName = collection ? collection.name : t("collection");
items.push({
type: "route",
title: (
<span>
const actions = React.useMemo(
() => [
createInternalLinkActionV2({
name: (
<Trans
defaults="New document in <em>{{ collectionName }}</em>"
values={{
@@ -44,37 +40,51 @@ function NewChildDocumentMenu({ document, label }: Props) {
em: <strong />,
}}
/>
</span>
),
to: newDocumentPath(document.collectionId),
});
}
),
section: ActiveDocumentSection,
visible: !!canCollection.createDocument,
to: newDocumentPath(document.collectionId),
}),
createInternalLinkActionV2({
name: (
<Trans
defaults="New document in <em>{{ collectionName }}</em>"
values={{
collectionName: document.titleWithDefault,
}}
components={{
em: <strong />,
}}
/>
),
section: ActiveDocumentSection,
visible: true,
to: newNestedDocumentPath(document.id),
}),
],
[
collectionName,
canCollection.createDocument,
document.id,
document.titleWithDefault,
document.collectionId,
]
);
items.push({
type: "route",
title: (
<span>
<Trans
defaults="New document in <em>{{ collectionName }}</em>"
values={{
collectionName: document.title,
}}
components={{
em: <strong />,
}}
/>
</span>
),
to: newNestedDocumentPath(document.id),
});
const rootAction = useMenuAction(actions);
return (
<>
<MenuButton {...menu}>{label}</MenuButton>
<ContextMenu {...menu} aria-label={t("New child document")}>
<Template {...menu} items={items} />
</ContextMenu>
</>
<Tooltip content={t("New document")} shortcut="n" placement="bottom">
<DropdownMenu
action={rootAction}
align="end"
ariaLabel={t("New child document")}
>
<Button icon={<PlusIcon />} neutral>
{t("New doc")}
</Button>
</DropdownMenu>
</Tooltip>
);
}
+22 -16
View File
@@ -1,13 +1,13 @@
import { observer } from "mobx-react";
import { useCallback } from "react";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import OAuthAuthentication from "~/models/oauth/OAuthAuthentication";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import ContextMenu from "~/components/ContextMenu";
import MenuItem from "~/components/ContextMenu/MenuItem";
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
import { useMenuState } from "~/hooks/useMenuState";
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
import useStores from "~/hooks/useStores";
import { createActionV2 } from "~/actions";
import { useMenuAction } from "~/hooks/useMenuAction";
type Props = {
/** The OAuthAuthentication to associate with the menu */
@@ -15,9 +15,6 @@ type Props = {
};
function OAuthAuthenticationMenu({ oauthAuthentication }: Props) {
const menu = useMenuState({
modal: true,
});
const { dialogs } = useStores();
const { t } = useTranslation();
@@ -42,15 +39,24 @@ function OAuthAuthenticationMenu({ oauthAuthentication }: Props) {
});
}, [t, dialogs, oauthAuthentication]);
const actions = useMemo(
() => [
createActionV2({
name: t("Revoke"),
section: "OAuth",
dangerous: true,
perform: handleRevoke,
}),
],
[t, handleRevoke]
);
const rootAction = useMenuAction(actions);
return (
<>
<OverflowMenuButton aria-label={t("Show menu")} {...menu} />
<ContextMenu {...menu}>
<MenuItem {...menu} onClick={handleRevoke} dangerous>
{t("Revoke")}
</MenuItem>
</ContextMenu>
</>
<DropdownMenu action={rootAction} ariaLabel={t("Show menu")}>
<OverflowMenuButton />
</DropdownMenu>
);
}
+35 -33
View File
@@ -1,14 +1,20 @@
import { observer } from "mobx-react";
import { useCallback } from "react";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import OAuthClient from "~/models/oauth/OAuthClient";
import OAuthClientDeleteDialog from "~/scenes/Settings/components/OAuthClientDeleteDialog";
import ContextMenu from "~/components/ContextMenu";
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
import Template from "~/components/ContextMenu/Template";
import { useMenuState } from "~/hooks/useMenuState";
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
import useStores from "~/hooks/useStores";
import { settingsPath } from "~/utils/routeHelpers";
import {
ActionV2Separator,
createActionV2,
createInternalLinkActionV2,
} from "~/actions";
import { useMenuAction } from "~/hooks/useMenuAction";
const Section = "OAuth";
type Props = {
/** The oauthClient to associate with the menu */
@@ -18,9 +24,6 @@ type Props = {
};
function OAuthClientMenu({ oauthClient, showEdit }: Props) {
const menu = useMenuState({
modal: true,
});
const { dialogs } = useStores();
const { t } = useTranslation();
@@ -36,32 +39,31 @@ function OAuthClientMenu({ oauthClient, showEdit }: Props) {
});
}, [t, dialogs, oauthClient]);
const actions = useMemo(
() => [
createInternalLinkActionV2({
name: `${t("Edit")}`,
section: Section,
visible: showEdit,
to: settingsPath("applications", oauthClient.id),
}),
ActionV2Separator,
createActionV2({
name: `${t("Delete")}`,
section: Section,
dangerous: true,
perform: handleDelete,
}),
],
[t, showEdit, oauthClient.id, handleDelete]
);
const rootAction = useMenuAction(actions);
return (
<>
<OverflowMenuButton aria-label={t("Show menu")} {...menu} />
<ContextMenu {...menu}>
<Template
{...menu}
items={[
{
type: "route",
title: `${t("Edit")}`,
visible: showEdit,
to: settingsPath("applications", oauthClient.id),
},
{
type: "separator",
},
{
type: "button",
dangerous: true,
title: `${t("Delete")}`,
onClick: handleDelete,
},
]}
/>
</ContextMenu>
</>
<DropdownMenu action={rootAction} ariaLabel={t("Show menu")}>
<OverflowMenuButton />
</DropdownMenu>
);
}
+22 -28
View File
@@ -1,51 +1,45 @@
import { observer } from "mobx-react";
import { useTranslation } from "react-i18next";
import Document from "~/models/Document";
import ContextMenu from "~/components/ContextMenu";
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
import Template from "~/components/ContextMenu/Template";
import { actionToMenuItem } from "~/actions";
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
import { ActionV2Separator } from "~/actions";
import {
copyLinkToRevision,
restoreRevision,
} from "~/actions/definitions/revisions";
import useActionContext from "~/hooks/useActionContext";
import { useMenuState } from "~/hooks/useMenuState";
import separator from "./separator";
import { useMemo } from "react";
import { useMenuAction } from "~/hooks/useMenuAction";
type Props = {
document: Document;
revisionId: string;
className?: string;
};
function RevisionMenu({ document, className }: Props) {
const menu = useMenuState({
modal: true,
});
function RevisionMenu({ document }: Props) {
const { t } = useTranslation();
const context = useActionContext({
isContextMenu: true,
activeDocumentId: document.id,
});
const actions = useMemo(
() => [restoreRevision, ActionV2Separator, copyLinkToRevision],
[]
);
const rootAction = useMenuAction(actions);
return (
<>
<OverflowMenuButton
className={className}
aria-label={t("Show menu")}
{...menu}
/>
<ContextMenu {...menu} aria-label={t("Revision options")}>
<Template
{...menu}
items={[
actionToMenuItem(restoreRevision, context),
separator(),
actionToMenuItem(copyLinkToRevision, context),
]}
/>
</ContextMenu>
</>
<DropdownMenu
action={rootAction}
context={context}
align="end"
ariaLabel={t("Revision options")}
>
<OverflowMenuButton />
</DropdownMenu>
);
}
+2 -20
View File
@@ -1,10 +1,5 @@
import { observer } from "mobx-react";
import {
TableOfContentsIcon,
EditIcon,
PlusIcon,
MoreIcon,
} from "outline-icons";
import { TableOfContentsIcon, EditIcon, MoreIcon } from "outline-icons";
import { useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
@@ -306,20 +301,7 @@ function DocumentHeader({
!isCompact &&
!isMobile && (
<Action>
<NewChildDocumentMenu
document={document}
label={(props) => (
<Tooltip
content={t("New document")}
shortcut="n"
placement="bottom"
>
<Button icon={<PlusIcon />} {...props} neutral>
{t("New doc")}
</Button>
</Tooltip>
)}
/>
<NewChildDocumentMenu document={document} />
</Action>
)}
{revision && revision.createdAt !== document.updatedAt && (
+1 -1
View File
@@ -134,7 +134,7 @@ type BaseActionV2 = {
type: "action";
id: string;
analyticsName?: string;
name: ((context: ActionContext) => string) | string;
name: ((context: ActionContext) => React.ReactNode) | React.ReactNode;
section: ((context: ActionContext) => string) | string;
shortcut?: string[];
keywords?: string;