chore: Move SuggestionsMenu to Radix (#11644)

* chore: Move SuggestionsMenu to Radix

* Restore bounce anim

* fix: Clear query on button open

* Sub-menu support

* fix bugs

* PR feedback
This commit is contained in:
Tom Moor
2026-03-05 17:45:19 -05:00
committed by GitHub
parent 3222a77cc3
commit ace351035a
5 changed files with 439 additions and 240 deletions
+1 -1
View File
@@ -129,7 +129,7 @@ const StyledContent = styled(PopoverPrimitive.Content)<StyledContentProps>`
`}
&[data-state="open"] {
animation: ${fadeAndScaleIn} 150ms cubic-bezier(0.08, 0.82, 0.17, 1); // ease-out-circ
animation: ${fadeAndScaleIn} 150ms cubic-bezier(0.08, 0.82, 0.17, 1);
}
`;
+1
View File
@@ -20,6 +20,7 @@ function BlockMenu(props: Props) {
icon={item.icon}
title={item.title}
shortcut={item.shortcut}
disclosure={options.disclosure}
/>
),
[]
+428 -233
View File
@@ -6,21 +6,26 @@ import { TextSelection } from "prosemirror-state";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { toast } from "sonner";
import styled from "styled-components";
import styled, { keyframes } from "styled-components";
import insertFiles from "@shared/editor/commands/insertFiles";
import { EmbedDescriptor } from "@shared/editor/embeds";
import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators";
import { findParentNode } from "@shared/editor/queries/findParentNode";
import type { MenuItem } from "@shared/editor/types";
import { depths, s } from "@shared/styles";
import { s } from "@shared/styles";
import { getEventFiles } from "@shared/utils/files";
import { AttachmentValidation } from "@shared/validations";
import { Portal } from "~/components/Portal";
import {
Drawer,
DrawerContent,
DrawerTitle,
} from "~/components/primitives/Drawer";
import {
Popover,
PopoverAnchor,
PopoverContent,
} from "~/components/primitives/Popover";
import { MouseSafeArea } from "~/components/MouseSafeArea";
import Scrollable from "~/components/Scrollable";
import useDictionary from "~/hooks/useDictionary";
import useMobile from "~/hooks/useMobile";
@@ -29,38 +34,6 @@ import { useEditor } from "./EditorContext";
import Input from "./Input";
import { MenuHeader } from "~/components/primitives/components/Menu";
type TopAnchor = {
top: number;
bottom: undefined;
};
type BottomAnchor = {
top: undefined;
bottom: number;
};
type LeftAnchor = {
left: number;
right: undefined;
};
type RightAnchor = {
left: undefined;
right: number;
};
type Position = ((TopAnchor | BottomAnchor) & (LeftAnchor | RightAnchor)) & {
isAbove: boolean;
};
const defaultPosition: Position = {
top: 0,
bottom: undefined,
left: -10000,
right: undefined,
isAbove: false,
};
export type Props<T extends MenuItem = MenuItem> = {
rtl: boolean;
isActive: boolean;
@@ -80,6 +53,7 @@ export type Props<T extends MenuItem = MenuItem> = {
index: number,
options: {
selected: boolean;
disclosure?: boolean;
onClick: (event: React.SyntheticEvent) => void;
}
) => React.ReactNode;
@@ -92,23 +66,65 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
const dictionary = useDictionary();
const { t } = useTranslation();
const isMobile = useMobile();
const hasActivated = React.useRef(false);
const pointerRef = React.useRef<{ clientX: number; clientY: number }>({
clientX: 0,
clientY: 0,
});
const menuRef = React.useRef<HTMLDivElement>(null);
const inputRef = React.useRef<HTMLInputElement>(null);
const selectionRef = React.useRef<{ from: number; to: number } | null>(null);
const [position, setPosition] = React.useState<Position>(defaultPosition);
const [insertItem, setInsertItem] = React.useState<
MenuItem | EmbedDescriptor
>();
const [selectedIndex, setSelectedIndex] = React.useState(0);
const [submenu, setSubmenu] = React.useState<{
index: number;
items: MenuItem[];
selectedIndex: number;
} | null>(null);
const itemRefs = React.useRef<Map<number, HTMLElement>>(new Map());
const submenuContentRef = React.useRef<HTMLDivElement>(null);
const hoverTimerRef = React.useRef<ReturnType<typeof setTimeout>>();
// Stores the caret bounding rect, snapshotted when the menu opens
const caretRectRef = React.useRef(new DOMRect());
// Stable virtual element for Radix PopoverAnchor never replaced so the
// popper does not trigger unnecessary anchor-change cycles.
const caretRef = React.useRef({
getBoundingClientRect: () => caretRectRef.current,
});
// Compute and store the caret rect during render so it is available before
// the Radix popper effect runs for the first time.
const caretRect = React.useMemo(() => {
if (!props.isActive) {
return new DOMRect();
}
try {
const { selection } = view.state;
const fromPos = view.coordsAtPos(selection.from);
const toPos = view.coordsAtPos(selection.to, -1);
const top = Math.min(fromPos.top, toPos.top);
const bottom = Math.max(fromPos.bottom, toPos.bottom);
const left = Math.min(fromPos.left, toPos.left);
const right = Math.max(fromPos.right, toPos.right);
return new DOMRect(left, top, right - left, bottom - top);
} catch (err) {
Logger.warn("Unable to calculate caret position", err);
return new DOMRect();
}
}, [props.isActive, view]);
caretRectRef.current = caretRect;
const resolveChildren = (
children: MenuItem["children"]
): MenuItem[] | undefined =>
typeof children === "function" ? children() : children;
React.useEffect(() => {
if (props.isActive) {
hasActivated.current = true;
// Save the selection position when the menu opens. On mobile, the editor
// may lose focus/selection when tapping on menu items, so we restore it.
requestAnimationFrame(() => {
@@ -121,81 +137,21 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.isActive]);
const calculatePosition = React.useCallback(
(props: Props) => {
if (!props.isActive) {
return defaultPosition;
}
React.useEffect(() => {
setSubmenu(null);
const caretPosition = () => {
let fromPos;
let toPos;
try {
fromPos = view.coordsAtPos(selection.from);
toPos = view.coordsAtPos(selection.to, -1);
} catch (err) {
Logger.warn("Unable to calculate caret position", err);
return {
top: 0,
bottom: 0,
left: 0,
right: 0,
};
}
if (!props.isActive) {
return;
}
// ensure that start < end for the menu to be positioned correctly
return {
top: Math.min(fromPos.top, toPos.top),
bottom: Math.max(fromPos.bottom, toPos.bottom),
left: Math.min(fromPos.left, toPos.left),
right: Math.max(fromPos.right, toPos.right),
};
};
setSelectedIndex(0);
setInsertItem(undefined);
}, [props.isActive]);
const { selection } = view.state;
const ref = menuRef.current;
const offsetWidth = ref ? ref.offsetWidth : 0;
const offsetHeight = ref ? ref.offsetHeight : 0;
const { top, bottom, right, left } = caretPosition();
const margin = 12;
const offsetParent = ref?.offsetParent
? ref.offsetParent.getBoundingClientRect()
: ({
width: 0,
height: 0,
top: 0,
left: 0,
} as DOMRect);
let leftPos = Math.min(
left - offsetParent.left,
window.innerWidth - offsetParent.left - offsetWidth - margin
);
if (props.rtl) {
leftPos = right - offsetWidth;
}
if (top - offsetHeight > margin) {
return {
left: leftPos,
top: undefined,
bottom: offsetParent.bottom - top,
right: undefined,
isAbove: false,
};
} else {
return {
left: leftPos,
top: bottom - offsetParent.top,
bottom: undefined,
right: undefined,
isAbove: true,
};
}
},
[view]
);
React.useEffect(() => {
setSelectedIndex(0);
setSubmenu(null);
}, [props.search]);
const handleClearSearch = React.useCallback(() => {
const { state, dispatch } = view;
@@ -226,26 +182,6 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
);
}, [props.search, props.trigger, view]);
React.useEffect(() => {
if (!props.isActive) {
return;
}
// reset scroll position to top when opening menu as the contents are
// hidden, not unrendered
if (menuRef.current) {
menuRef.current.scroll({ top: 0 });
}
setPosition(calculatePosition(props));
setSelectedIndex(0);
setInsertItem(undefined);
}, [calculatePosition, props.isActive]);
React.useEffect(() => {
setSelectedIndex(0);
}, [props.search]);
const restoreSelection = React.useCallback(() => {
if (!isMobile) {
return;
@@ -481,11 +417,47 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
}
const searchInput = search.toLowerCase();
const matchesSearch = (item: MenuItem | EmbedDescriptor) =>
(item.name || "").toLocaleLowerCase().includes(searchInput) ||
(item.title || "").toLocaleLowerCase().includes(searchInput) ||
(item.keywords || "").toLocaleLowerCase().includes(searchInput);
// When searching, flatten matching children into the top-level list so
// they are directly navigable with the keyboard. If all children match,
// exclude the parent item since it would be redundant.
const fullyFlattenedParents = new Set<MenuItem | EmbedDescriptor>();
if (search && filterable) {
const flattened: (EmbedDescriptor | MenuItem)[] = [];
for (const item of items) {
if ("children" in item && item.children) {
const children = resolveChildren(item.children);
if (children) {
const matching = children.filter(matchesSearch);
if (matching.length > 0) {
for (const child of matching) {
const { children: _, ...flat } = child;
flattened.push(flat);
}
if (matching.length === children.length) {
fullyFlattenedParents.add(item);
}
}
}
}
}
items = items.concat(flattened);
}
const filtered = items.filter((item) => {
if (item.name === "separator") {
return true;
}
if (fullyFlattenedParents.has(item)) {
return false;
}
if (item.visible === false) {
return false;
}
@@ -514,11 +486,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
return item;
}
return (
(item.name || "").toLocaleLowerCase().includes(searchInput) ||
(item.title || "").toLocaleLowerCase().includes(searchInput) ||
(item.keywords || "").toLocaleLowerCase().includes(searchInput)
);
return matchesSearch(item);
});
return filterExcessSeparators(
@@ -541,18 +509,40 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
);
}, [commands, props]);
React.useEffect(() => {
const handleMouseDown = (event: MouseEvent) => {
if (
!menuRef.current ||
menuRef.current.contains(event.target as Element)
) {
const openSubmenu = React.useCallback(
(index: number) => {
const item = filtered[index];
if (!item) {
return;
}
const children = resolveChildren(
"children" in item ? item.children : undefined
);
if (!children?.length) {
return;
}
props.onClose();
};
const normalized = filterExcessSeparators(
children.filter((child) => child.visible !== false)
);
const firstSelectable = normalized.findIndex(
(child) =>
child.name !== "separator" && !("disabled" in child && child.disabled)
);
if (firstSelectable === -1) {
return;
}
setSubmenu({
index,
items: normalized,
selectedIndex: firstSelectable,
});
},
[filtered]
);
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.isComposing) {
return;
@@ -561,18 +551,109 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
return;
}
// Let the link input's own handlers manage navigation keys
if (insertItem) {
return;
}
// --- Submenu open: route keys into it ---
if (submenu) {
if (event.key === "ArrowDown" || (event.ctrlKey && event.key === "n")) {
event.preventDefault();
event.stopPropagation();
const total = submenu.items.length - 1;
let next = submenu.selectedIndex + 1;
while (next <= total) {
const child = submenu.items[next];
if (
child?.name !== "separator" &&
!("disabled" in child && child.disabled)
) {
break;
}
next++;
}
if (next <= total) {
setSubmenu((s) => (s ? { ...s, selectedIndex: next } : s));
}
return;
}
if (event.key === "ArrowUp" || (event.ctrlKey && event.key === "p")) {
event.preventDefault();
event.stopPropagation();
let prev = submenu.selectedIndex - 1;
while (prev >= 0) {
const child = submenu.items[prev];
if (
child?.name !== "separator" &&
!("disabled" in child && child.disabled)
) {
break;
}
prev--;
}
if (prev >= 0) {
setSubmenu((s) => (s ? { ...s, selectedIndex: prev } : s));
}
return;
}
if (event.key === "ArrowLeft" || event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
setSubmenu(null);
return;
}
if (event.key === "Enter") {
event.preventDefault();
event.stopPropagation();
const child = submenu.items[submenu.selectedIndex];
if (child) {
handleClickItem(child);
setSubmenu(null);
}
return;
}
return;
}
// --- Normal (no submenu) ---
if (event.key === "Enter") {
event.preventDefault();
const item = filtered[selectedIndex];
if (item) {
handleClickItem(item);
const children = resolveChildren(
"children" in item ? item.children : undefined
);
if (children?.length) {
openSubmenu(selectedIndex);
} else {
handleClickItem(item);
}
} else {
props.onClose(true);
}
}
if (event.key === "ArrowRight") {
const item = filtered[selectedIndex];
if (item) {
const children = resolveChildren(
"children" in item ? item.children : undefined
);
if (children?.length) {
event.preventDefault();
event.stopPropagation();
openSubmenu(selectedIndex);
return;
}
}
}
if (
event.key === "ArrowUp" ||
(event.key === "Tab" && event.shiftKey) ||
@@ -636,18 +717,16 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
}
};
window.addEventListener("mousedown", handleMouseDown);
window.addEventListener("keydown", handleKeyDown, {
capture: true,
});
return () => {
window.removeEventListener("mousedown", handleMouseDown);
window.removeEventListener("keydown", handleKeyDown, {
capture: true,
});
};
}, [close, filtered, handleClickItem, props, selectedIndex]);
}, [close, filtered, handleClickItem, insertItem, openSubmenu, props, selectedIndex, submenu]);
const { isActive, uploadFile } = props;
const items = filtered;
@@ -675,6 +754,23 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
</VisuallyHidden.Root>
);
// Close submenu when parent selection moves away from the trigger
React.useEffect(() => {
if (submenu && submenu.index !== selectedIndex) {
setSubmenu(null);
}
}, [selectedIndex, submenu]);
// Cleanup hover timer on unmount
React.useEffect(
() => () => {
if (hoverTimerRef.current) {
clearTimeout(hoverTimerRef.current);
}
},
[]
);
const renderItems = () => {
let prevHeading: string | undefined;
@@ -693,6 +789,10 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
return null;
}
const hasChildren = !!(
"children" in item && resolveChildren(item.children)?.length
);
const handlePointerMove = (ev: React.PointerEvent) => {
if (
!("disabled" in item && item.disabled) &&
@@ -708,6 +808,22 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
clientX: ev.clientX,
clientY: ev.clientY,
};
// Hover to open submenu with delay
if (hasChildren) {
if (hoverTimerRef.current) {
clearTimeout(hoverTimerRef.current);
}
hoverTimerRef.current = setTimeout(() => {
openSubmenu(index);
}, 150);
} else {
// Close submenu when hovering a regular item
if (hoverTimerRef.current) {
clearTimeout(hoverTimerRef.current);
}
setSubmenu(null);
}
};
const handlePointerDown = () => {
@@ -722,23 +838,37 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
const handleOnClick = (ev: React.MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
handleClickItem(item);
if (hasChildren) {
openSubmenu(index);
} else {
handleClickItem(item);
}
};
const currentHeading =
"section" in item ? item.section?.({ t }) : undefined;
const itemRef = (node: HTMLElement | null) => {
if (node) {
itemRefs.current.set(index, node);
} else {
itemRefs.current.delete(index);
}
};
const response = (
<React.Fragment key={`${index}-${item.name}`}>
{currentHeading !== prevHeading && (
<MenuHeader key={currentHeading}>{currentHeading}</MenuHeader>
)}
<ListItem
ref={itemRef}
onPointerMove={handlePointerMove}
onPointerDown={handlePointerDown}
>
{props.renderMenuItem(item as any, index, {
selected: index === selectedIndex,
disclosure: hasChildren,
onClick: handleOnClick,
})}
</ListItem>
@@ -792,37 +922,152 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
}
return (
<Portal>
<Wrapper active={isActive} ref={menuRef} hiddenScrollbars {...position}>
{(isActive || hasActivated.current) && (
<>
{insertItem ? (
<LinkInputWrapper>
<LinkInput
type="text"
placeholder={
"placeholder" in insertItem && !!insertItem.placeholder
? insertItem.placeholder
: insertItem.title
? dictionary.pasteLinkWithTitle(insertItem.title)
: dictionary.pasteLink
<>
<Popover open={isActive} onOpenChange={handleOpenChange} modal={false}>
<PopoverAnchor virtualRef={caretRef} />
<BouncyPopoverContent
side="bottom"
align="start"
width={280}
shrink
style={{
padding: 0,
maxHeight:
"min(324px, var(--radix-popover-content-available-height))",
}}
onOpenAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
onInteractOutside={(e) => {
if (
submenuContentRef.current?.contains(
e.target as Node
)
) {
e.preventDefault();
}
}}
>
{insertItem ? (
<LinkInputWrapper>
<LinkInput
type="text"
placeholder={
"placeholder" in insertItem && !!insertItem.placeholder
? insertItem.placeholder
: insertItem.title
? dictionary.pasteLinkWithTitle(insertItem.title)
: dictionary.pasteLink
}
onKeyDown={handleLinkInputKeydown}
onPaste={handleLinkInputPaste}
autoFocus
/>
</LinkInputWrapper>
) : (
<List>{renderItems()}</List>
)}
{fileInput}
</BouncyPopoverContent>
</Popover>
{submenu && itemRefs.current.get(submenu.index) && (
<Popover open modal={false}>
<PopoverAnchor
virtualRef={{
current: {
getBoundingClientRect: () =>
itemRefs.current
.get(submenu.index)!
.getBoundingClientRect(),
},
}}
/>
<SubmenuPopoverContent
ref={submenuContentRef}
side="right"
align="start"
sideOffset={0}
width={220}
shrink
style={{ padding: 0 }}
onOpenAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={(e) => e.preventDefault()}
onPointerLeave={() => setSubmenu(null)}
>
<MouseSafeArea parentRef={submenuContentRef} />
<List>
{submenu.items.map((child, childIndex) => {
if (child.name === "separator") {
return (
<ListItem key={childIndex}>
<hr />
</ListItem>
);
}
if (!child.title) {
return null;
}
const handleChildPointerMove = (ev: React.PointerEvent) => {
if (
submenu.selectedIndex !== childIndex &&
(pointerRef.current.clientX !== ev.clientX ||
pointerRef.current.clientY !== ev.clientY)
) {
setSubmenu((s) =>
s ? { ...s, selectedIndex: childIndex } : s
);
}
onKeyDown={handleLinkInputKeydown}
onPaste={handleLinkInputPaste}
autoFocus
/>
</LinkInputWrapper>
) : (
<List>{renderItems()}</List>
)}
{fileInput}
</>
)}
</Wrapper>
</Portal>
pointerRef.current = {
clientX: ev.clientX,
clientY: ev.clientY,
};
};
const handleChildClick = (ev: React.MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
handleClickItem(child);
setSubmenu(null);
};
return (
<ListItem
key={`sub-${childIndex}-${child.name}`}
onPointerMove={handleChildPointerMove}
>
{props.renderMenuItem(child as any, childIndex, {
selected: childIndex === submenu.selectedIndex,
onClick: handleChildClick,
})}
</ListItem>
);
})}
</List>
</SubmenuPopoverContent>
</Popover>
)}
</>
);
}
const bouncyFadeIn = keyframes`
from {
opacity: 0;
transform: scale(0.95);
}
`;
const BouncyPopoverContent = styled(PopoverContent)`
&[data-state="open"] {
animation: ${bouncyFadeIn} 150ms cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
`;
const SubmenuPopoverContent = styled(PopoverContent)`
max-height: min(324px, var(--radix-popover-content-available-height));
`;
const LinkInputWrapper = styled.div`
margin: 8px;
`;
@@ -839,6 +1084,13 @@ const List = styled.ol`
height: 100%;
padding: 6px;
margin: 0;
white-space: nowrap;
hr {
border: 0;
height: 0;
border-top: 1px solid ${s("divider")};
}
`;
const ListItem = styled.li`
@@ -860,61 +1112,4 @@ const MobileScrollable = styled(Scrollable)`
max-height: 75vh;
`;
export const Wrapper = styled(Scrollable)<{
active: boolean;
top?: number;
bottom?: number;
left?: number;
isAbove: boolean;
}>`
color: ${s("textSecondary")};
font-family: ${s("fontFamily")};
position: absolute;
z-index: ${depths.editorToolbar};
${(props) => props.top !== undefined && `top: ${props.top}px`};
${(props) => props.bottom !== undefined && `bottom: ${props.bottom}px`};
left: ${(props) => props.left}px;
background: ${s("menuBackground")};
border-radius: 6px;
box-shadow:
rgba(0, 0, 0, 0.05) 0px 0px 0px 1px,
rgba(0, 0, 0, 0.08) 0px 4px 8px,
rgba(0, 0, 0, 0.08) 0px 2px 4px;
opacity: 0;
transform: scale(0.95);
transition:
opacity 150ms cubic-bezier(0.175, 0.885, 0.32, 1.275),
transform 150ms cubic-bezier(0.175, 0.885, 0.32, 1.275);
transition-delay: 150ms;
line-height: 0;
box-sizing: border-box;
pointer-events: none;
white-space: nowrap;
width: 280px;
height: auto;
max-height: 324px;
* {
box-sizing: border-box;
}
hr {
border: 0;
height: 0;
border-top: 1px solid ${s("divider")};
}
${({ active, isAbove }) =>
active &&
`
transform: translateY(${isAbove ? "6px" : "-6px"}) scale(1);
pointer-events: all;
opacity: 1;
`};
@media print {
display: none;
}
`;
export default SuggestionsMenu;
@@ -5,6 +5,7 @@ import styled from "styled-components";
import { usePortalContext } from "~/components/Portal";
import {
MenuButton,
MenuDisclosure,
MenuIconWrapper,
MenuLabel,
} from "~/components/primitives/components/Menu";
@@ -26,6 +27,8 @@ export type Props = {
subtitle?: React.ReactNode;
/** A string representing the keyboard shortcut for the item */
shortcut?: string;
/** Whether to show a disclosure arrow indicating a submenu */
disclosure?: boolean;
};
function SuggestionsMenuItem({
@@ -37,6 +40,7 @@ function SuggestionsMenuItem({
subtitle,
shortcut,
icon,
disclosure,
}: Props) {
const portal = usePortalContext();
const ref = React.useCallback(
@@ -75,6 +79,7 @@ function SuggestionsMenuItem({
)}
{shortcut && <Shortcut $active={selected}>{shortcut}</Shortcut>}
</MenuLabel>
{disclosure && <MenuDisclosure />}
</MenuButton>
);
}
+4 -6
View File
@@ -55,12 +55,10 @@ export default class BlockMenuExtension extends Suggestion {
Decoration.widget(
parent.pos,
() => {
button.addEventListener(
"click",
action(() => {
this.state.open = true;
})
);
button.onclick = action(() => {
this.state.query = "";
this.state.open = true;
});
return button;
},
{