Skip to content
File

Blob: src/client/components/editor/controllers/slash/panel.tsx

typescript122 lines
1import { useId, useImperativeHandle, useRef, type Ref } from "react";
2import { FloatingPortal } from "@floating-ui/react";
3import type { SuggestionKeyDownProps } from "@tiptap/suggestion";
4import { useMenuNavigation } from "../menu/navigation";
5import { preserveEditorSelectionOnMouseDown, useEditorRectPopover } from "../menu/popover";
6import type { ResolvedSlashMenuItem } from "./items";
7 
8export interface SlashMenuPanelProps {
9 items: ResolvedSlashMenuItem[];
10 command: (item: ResolvedSlashMenuItem) => void;
11 clientRect: (() => DOMRect | null) | null;
12 contextElement?: HTMLElement | null;
13 onClose?: () => void;
14 ref?: Ref<SlashMenuPanelHandle>;
15}
16 
17export interface SlashMenuPanelHandle {
18 onKeyDown: (props: SuggestionKeyDownProps) => boolean;
19}
20 
21export function SlashMenuPanel({ items, command, clientRect, contextElement, onClose, ref }: SlashMenuPanelProps) {
22 const panelRef = useRef<HTMLDivElement>(null);
23 const listboxId = useId();
24 const handleSelect = (item: ResolvedSlashMenuItem) => {
25 if (item.blockedReason) return;
26 command(item);
27 };
28 const navigation = useMenuNavigation({
29 items,
30 listRef: panelRef,
31 onSelect: handleSelect,
32 });
33 const { floatingStyles, setFloating } = useEditorRectPopover({
34 open: items.length > 0,
35 onClose,
36 getAnchorRect: () => clientRect?.() ?? null,
37 contextElement,
38 maxHeight: true,
39 deferOutsidePress: true,
40 });
41 
42 useImperativeHandle(ref, () => ({
43 onKeyDown: ({ event }: SuggestionKeyDownProps) => navigation.onKeyDown(event),
44 }));
45 
46 if (items.length === 0) return null;
47 
48 const groups: { name: string; items: { item: ResolvedSlashMenuItem; index: number }[] }[] = [];
49 for (let i = 0; i < items.length; i++) {
50 const item = items[i];
51 const last = groups[groups.length - 1];
52 if (!last || last.name !== item.group) {
53 groups.push({ name: item.group, items: [{ item, index: i }] });
54 } else {
55 last.items.push({ item, index: i });
56 }
57 }
58 
59 return (
60 <FloatingPortal>
61 <div
62 ref={(node) => {
63 setFloating(node);
64 panelRef.current = node;
65 }}
66 className="tiptap-slash-menu"
67 role="listbox"
68 aria-label="Insert block"
69 style={{ ...floatingStyles, zIndex: 80 }}
70 onMouseDownCapture={(e) => preserveEditorSelectionOnMouseDown(e)}
71 >
72 {groups.map((group) => {
73 const labelId = `${listboxId}-group-${group.name}`;
74 return (
75 <div key={group.name} role="group" aria-labelledby={labelId}>
76 <div id={labelId} className="tiptap-slash-menu-label">
77 {group.name}
78 </div>
79 {group.items.map(({ item, index }) => {
80 const disabled = Boolean(item.blockedReason);
81 const subtitle = item.blockedReason ?? item.description ?? null;
82 return (
83 <button
84 key={index}
85 id={`${listboxId}-option-${index}`}
86 type="button"
87 role="option"
88 data-menu-index={index}
89 className="tiptap-slash-menu-item"
90 aria-selected={index === navigation.selectedIndex}
91 aria-disabled={disabled || undefined}
92 title={item.blockedReason ?? undefined}
93 disabled={disabled}
94 onMouseEnter={() => navigation.setSelectedIndex(index)}
95 onMouseDown={(e) => {
96 e.preventDefault();
97 if (disabled) return;
98 command(item);
99 }}
100 >
101 <item.icon size={18} className="shrink-0 text-zinc-400" aria-hidden="true" />
102 <span className="flex min-w-0 flex-col text-left">
103 <span className="truncate">{item.title}</span>
104 {subtitle && (
105 <span
106 className={disabled ? "truncate text-xs text-amber-400/90" : "truncate text-xs text-zinc-500"}
107 >
108 {subtitle}
109 </span>
110 )}
111 </span>
112 </button>
113 );
114 })}
115 </div>
116 );
117 })}
118 </div>
119 </FloatingPortal>
120 );
121}