@edux-design/comments 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/README.md +61 -0
- package/dist/index.d.mts +52 -0
- package/dist/index.d.ts +52 -0
- package/dist/index.js +579 -0
- package/dist/index.mjs +556 -0
- package/package.json +51 -0
- package/src/demos/Comment.stories.jsx +472 -0
- package/src/demos/CommentComposer.stories.jsx +62 -0
- package/src/elements/Comment.jsx +234 -0
- package/src/elements/Comment.test.jsx +137 -0
- package/src/elements/CommentComposer.jsx +184 -0
- package/src/elements/CommentComposer.test.jsx +125 -0
- package/src/elements/CommentMention.jsx +217 -0
- package/src/elements/CommentMention.test.jsx +138 -0
- package/src/elements/CommentMentionExtension.jsx +56 -0
- package/src/index.js +3 -0
- package/tsconfig.json +10 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import React, { useMemo, useState, useCallback, useEffect, useRef } from "react";
|
|
2
|
+
import { Menu, MenuList, MenuItem } from "@edux-design/menus";
|
|
3
|
+
import { Avatar } from "@edux-design/avatars";
|
|
4
|
+
import { useCommentComposer } from "./CommentComposer";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @typedef {{
|
|
8
|
+
* id: string,
|
|
9
|
+
* name: string,
|
|
10
|
+
* handle: string,
|
|
11
|
+
* avatarProps?: import("@edux-design/avatars").AvatarProps,
|
|
12
|
+
* }} CommentMentionPerson
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @typedef {{
|
|
17
|
+
* peopleList: CommentMentionPerson[],
|
|
18
|
+
* placeholder?: string,
|
|
19
|
+
* }} CommentMentionProps
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export const CommentMention = ({
|
|
23
|
+
peopleList = [],
|
|
24
|
+
}) => {
|
|
25
|
+
const { editor, editorContainerRef } = useCommentComposer();
|
|
26
|
+
|
|
27
|
+
const [menuOpen, setMenuOpen] = useState(false);
|
|
28
|
+
const [query, setQuery] = useState("");
|
|
29
|
+
const [range, setRange] = useState(null);
|
|
30
|
+
const [anchorStyle, setAnchorStyle] = useState({ left: 0, top: 0 });
|
|
31
|
+
const menuListRef = useRef(null);
|
|
32
|
+
const rangeRef = useRef(null);
|
|
33
|
+
|
|
34
|
+
const peopleByHandle = useMemo(
|
|
35
|
+
() => new Map(peopleList.map((person) => [person.handle.toLowerCase(), person])),
|
|
36
|
+
[peopleList]
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
const filteredPeople = useMemo(() => {
|
|
40
|
+
if (!query) return peopleList;
|
|
41
|
+
const lowered = query.toLowerCase();
|
|
42
|
+
return peopleList.filter((person) =>
|
|
43
|
+
person.handle.toLowerCase().startsWith(lowered)
|
|
44
|
+
);
|
|
45
|
+
}, [peopleList, query]);
|
|
46
|
+
|
|
47
|
+
const updateMenuState = useCallback(() => {
|
|
48
|
+
if (!editor) return;
|
|
49
|
+
const { from } = editor.state.selection;
|
|
50
|
+
const { parent, parentOffset, pos } = editor.state.selection.$from;
|
|
51
|
+
|
|
52
|
+
if (!parent.isTextblock) {
|
|
53
|
+
setMenuOpen(false);
|
|
54
|
+
setQuery("");
|
|
55
|
+
setRange(null);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const textBefore = parent.textBetween(0, parentOffset, "\n", "\0");
|
|
60
|
+
const match = textBefore.match(/@([\w-]*)$/);
|
|
61
|
+
|
|
62
|
+
if (match) {
|
|
63
|
+
const handleQuery = match[1] ?? "";
|
|
64
|
+
setQuery(handleQuery);
|
|
65
|
+
setMenuOpen(true);
|
|
66
|
+
const fromPos = pos - match[0].length;
|
|
67
|
+
const nextRange = { from: fromPos, to: pos };
|
|
68
|
+
setRange(nextRange);
|
|
69
|
+
rangeRef.current = nextRange;
|
|
70
|
+
const coords = editor.view.coordsAtPos(fromPos);
|
|
71
|
+
const containerRect = editorContainerRef?.current?.getBoundingClientRect?.();
|
|
72
|
+
if (containerRect) {
|
|
73
|
+
setAnchorStyle({
|
|
74
|
+
left: coords.left - containerRect.left + 12,
|
|
75
|
+
top: coords.bottom - containerRect.top + 4,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
setMenuOpen(false);
|
|
82
|
+
setQuery("");
|
|
83
|
+
setRange(null);
|
|
84
|
+
rangeRef.current = null;
|
|
85
|
+
}, [editor]);
|
|
86
|
+
|
|
87
|
+
const handleSelectPerson = useCallback(
|
|
88
|
+
(person) => {
|
|
89
|
+
if (!editor || !rangeRef.current) return;
|
|
90
|
+
const selectionRange = rangeRef.current;
|
|
91
|
+
editor.commands.focus();
|
|
92
|
+
editor.commands.setTextSelection(selectionRange);
|
|
93
|
+
editor.commands.insertContentAt(
|
|
94
|
+
selectionRange,
|
|
95
|
+
[
|
|
96
|
+
{
|
|
97
|
+
type: "commentMention",
|
|
98
|
+
attrs: {
|
|
99
|
+
id: person.id,
|
|
100
|
+
handle: person.handle,
|
|
101
|
+
label: person.name,
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
{ type: "text", text: " " },
|
|
105
|
+
],
|
|
106
|
+
{ updateSelection: false }
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const nextPos = selectionRange.from + 2;
|
|
110
|
+
requestAnimationFrame(() => {
|
|
111
|
+
editor.commands.setTextSelection(nextPos);
|
|
112
|
+
editor.view.focus();
|
|
113
|
+
setTimeout(() => editor.view.focus(), 0);
|
|
114
|
+
});
|
|
115
|
+
setMenuOpen(false);
|
|
116
|
+
setQuery("");
|
|
117
|
+
setRange(null);
|
|
118
|
+
rangeRef.current = null;
|
|
119
|
+
},
|
|
120
|
+
[editor]
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
useEffect(() => {
|
|
124
|
+
if (!editor) return;
|
|
125
|
+
updateMenuState();
|
|
126
|
+
editor.on("transaction", updateMenuState);
|
|
127
|
+
editor.on("selectionUpdate", updateMenuState);
|
|
128
|
+
return () => {
|
|
129
|
+
editor.off("transaction", updateMenuState);
|
|
130
|
+
editor.off("selectionUpdate", updateMenuState);
|
|
131
|
+
};
|
|
132
|
+
}, [editor, updateMenuState]);
|
|
133
|
+
|
|
134
|
+
useEffect(() => {
|
|
135
|
+
if (!editor) return;
|
|
136
|
+
const handleKeyDown = (event) => {
|
|
137
|
+
if (!menuOpen) return;
|
|
138
|
+
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
|
|
139
|
+
|
|
140
|
+
event.preventDefault();
|
|
141
|
+
const list = menuListRef.current;
|
|
142
|
+
const items = list?.querySelectorAll("[data-menu-item]");
|
|
143
|
+
if (!list || !items?.length) return;
|
|
144
|
+
|
|
145
|
+
list.focus();
|
|
146
|
+
const direction = event.key === "ArrowDown" ? 1 : -1;
|
|
147
|
+
const targetIndex = direction === 1 ? 0 : items.length - 1;
|
|
148
|
+
items[targetIndex]?.focus();
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const dom = editor.view?.dom;
|
|
152
|
+
dom?.addEventListener("keydown", handleKeyDown);
|
|
153
|
+
return () => dom?.removeEventListener("keydown", handleKeyDown);
|
|
154
|
+
}, [editor, menuOpen]);
|
|
155
|
+
|
|
156
|
+
const menuContent = useMemo(
|
|
157
|
+
() => (
|
|
158
|
+
<Menu
|
|
159
|
+
open={menuOpen && filteredPeople.length > 0}
|
|
160
|
+
onOpenChange={setMenuOpen}
|
|
161
|
+
button={
|
|
162
|
+
<button
|
|
163
|
+
type="button"
|
|
164
|
+
aria-hidden
|
|
165
|
+
className="absolute h-1 w-1 opacity-0"
|
|
166
|
+
style={{
|
|
167
|
+
left: `${anchorStyle.left}px`,
|
|
168
|
+
top: `${anchorStyle.top}px`,
|
|
169
|
+
pointerEvents: "none",
|
|
170
|
+
}}
|
|
171
|
+
/>
|
|
172
|
+
}
|
|
173
|
+
align="start"
|
|
174
|
+
side="bottom"
|
|
175
|
+
trapFocus={false}
|
|
176
|
+
disableOutsidePointerEvents={false}
|
|
177
|
+
onOpenAutoFocus={(event) => event.preventDefault()}
|
|
178
|
+
contentProps={{
|
|
179
|
+
sideOffset: 4,
|
|
180
|
+
avoidCollisions: true,
|
|
181
|
+
collisionPadding: 12,
|
|
182
|
+
onCloseAutoFocus: (event) => event.preventDefault(),
|
|
183
|
+
}}
|
|
184
|
+
>
|
|
185
|
+
<MenuList ref={menuListRef} className="min-w-[220px]">
|
|
186
|
+
{filteredPeople.map((person) => (
|
|
187
|
+
<MenuItem
|
|
188
|
+
key={person.id}
|
|
189
|
+
onMouseDown={(event) => {
|
|
190
|
+
event.preventDefault();
|
|
191
|
+
event.stopPropagation();
|
|
192
|
+
handleSelectPerson(person);
|
|
193
|
+
}}
|
|
194
|
+
onClick={() => handleSelectPerson(person)}
|
|
195
|
+
onPointerDown={(event) => event.preventDefault()}
|
|
196
|
+
>
|
|
197
|
+
<span className="flex items-center gap-8">
|
|
198
|
+
<Avatar size="small" {...person.avatarProps} />
|
|
199
|
+
<span className="flex flex-col">
|
|
200
|
+
<span className="text-sm text-fg-base">{person.name}</span>
|
|
201
|
+
<span className="text-xs text-fg-subtle">
|
|
202
|
+
@{person.handle}
|
|
203
|
+
</span>
|
|
204
|
+
</span>
|
|
205
|
+
</span>
|
|
206
|
+
</MenuItem>
|
|
207
|
+
))}
|
|
208
|
+
</MenuList>
|
|
209
|
+
</Menu>
|
|
210
|
+
),
|
|
211
|
+
[filteredPeople, handleSelectPerson, menuOpen]
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
if (!editor) return null;
|
|
215
|
+
|
|
216
|
+
return menuContent;
|
|
217
|
+
};
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
3
|
+
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
|
|
4
|
+
import "@testing-library/jest-dom";
|
|
5
|
+
|
|
6
|
+
const insertContentAt = vi.fn();
|
|
7
|
+
const setTextSelection = vi.fn();
|
|
8
|
+
const focus = vi.fn();
|
|
9
|
+
const viewFocus = vi.fn();
|
|
10
|
+
|
|
11
|
+
const editorMock = {
|
|
12
|
+
state: {
|
|
13
|
+
selection: {
|
|
14
|
+
from: 10,
|
|
15
|
+
$from: {
|
|
16
|
+
parent: {
|
|
17
|
+
isTextblock: true,
|
|
18
|
+
textBetween: () => "Hello @al",
|
|
19
|
+
},
|
|
20
|
+
parentOffset: 9,
|
|
21
|
+
pos: 10,
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
view: {
|
|
26
|
+
dom: document.createElement("div"),
|
|
27
|
+
coordsAtPos: () => ({ left: 100, bottom: 120 }),
|
|
28
|
+
focus: viewFocus,
|
|
29
|
+
},
|
|
30
|
+
commands: {
|
|
31
|
+
focus,
|
|
32
|
+
setTextSelection,
|
|
33
|
+
insertContentAt,
|
|
34
|
+
},
|
|
35
|
+
on: vi.fn(),
|
|
36
|
+
off: vi.fn(),
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const containerRef = {
|
|
40
|
+
current: {
|
|
41
|
+
getBoundingClientRect: () => ({ left: 50, top: 50 }),
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
vi.mock("@edux-design/menus", () => ({
|
|
46
|
+
Menu: ({ children, button }) => (
|
|
47
|
+
<div>
|
|
48
|
+
{button}
|
|
49
|
+
{children}
|
|
50
|
+
</div>
|
|
51
|
+
),
|
|
52
|
+
MenuList: React.forwardRef(({ children }, ref) => (
|
|
53
|
+
<div ref={ref} tabIndex={0}>
|
|
54
|
+
{children}
|
|
55
|
+
</div>
|
|
56
|
+
)),
|
|
57
|
+
MenuItem: ({ children, onMouseDown, onClick }) => (
|
|
58
|
+
<div
|
|
59
|
+
data-testid="menu-item"
|
|
60
|
+
data-menu-item
|
|
61
|
+
tabIndex={-1}
|
|
62
|
+
onMouseDown={onMouseDown}
|
|
63
|
+
onClick={onClick}
|
|
64
|
+
>
|
|
65
|
+
{children}
|
|
66
|
+
</div>
|
|
67
|
+
),
|
|
68
|
+
}));
|
|
69
|
+
|
|
70
|
+
vi.mock("@edux-design/avatars", () => ({
|
|
71
|
+
Avatar: () => <div data-testid="avatar" />,
|
|
72
|
+
}));
|
|
73
|
+
|
|
74
|
+
vi.mock("@edux-design/mentions", () => ({
|
|
75
|
+
Mention: ({ children }) => <span>{children}</span>,
|
|
76
|
+
}));
|
|
77
|
+
|
|
78
|
+
vi.mock("./CommentComposer", () => ({
|
|
79
|
+
useCommentComposer: () => ({
|
|
80
|
+
editor: editorMock,
|
|
81
|
+
editorContainerRef: containerRef,
|
|
82
|
+
}),
|
|
83
|
+
}));
|
|
84
|
+
|
|
85
|
+
import { CommentMention } from "./CommentMention";
|
|
86
|
+
|
|
87
|
+
afterEach(() => {
|
|
88
|
+
cleanup();
|
|
89
|
+
vi.clearAllMocks();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe("CommentMention", () => {
|
|
93
|
+
it("inserts a mention and focuses the editor on selection", () => {
|
|
94
|
+
render(
|
|
95
|
+
<CommentMention
|
|
96
|
+
peopleList={[{ id: "1", name: "Alex Johnson", handle: "alex" }]}
|
|
97
|
+
/>
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
const item = screen.getByTestId("menu-item");
|
|
101
|
+
fireEvent.mouseDown(item);
|
|
102
|
+
|
|
103
|
+
expect(focus).toHaveBeenCalled();
|
|
104
|
+
expect(setTextSelection).toHaveBeenCalled();
|
|
105
|
+
expect(insertContentAt).toHaveBeenCalled();
|
|
106
|
+
expect(viewFocus).toHaveBeenCalled();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("positions the menu trigger relative to the cursor", () => {
|
|
110
|
+
render(
|
|
111
|
+
<CommentMention
|
|
112
|
+
peopleList={[{ id: "1", name: "Alex Johnson", handle: "alex" }]}
|
|
113
|
+
/>
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
const trigger = screen.getByRole("button", { hidden: true });
|
|
117
|
+
expect(trigger.style.left).toBe("62px");
|
|
118
|
+
expect(trigger.style.top).toBe("74px");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("moves focus to menu items on arrow key", () => {
|
|
122
|
+
render(
|
|
123
|
+
<CommentMention
|
|
124
|
+
peopleList={[
|
|
125
|
+
{ id: "1", name: "Alex Johnson", handle: "alex" },
|
|
126
|
+
{ id: "2", name: "Priya Singh", handle: "priya" },
|
|
127
|
+
]}
|
|
128
|
+
/>
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
const items = screen.getAllByTestId("menu-item");
|
|
132
|
+
const focusSpy = vi.spyOn(items[0], "focus");
|
|
133
|
+
|
|
134
|
+
fireEvent.keyDown(editorMock.view.dom, { key: "ArrowDown" });
|
|
135
|
+
|
|
136
|
+
expect(focusSpy).toHaveBeenCalled();
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Node, mergeAttributes } from "@tiptap/core";
|
|
3
|
+
import { ReactNodeViewRenderer, NodeViewWrapper } from "@tiptap/react";
|
|
4
|
+
import { Mention } from "@edux-design/mentions";
|
|
5
|
+
|
|
6
|
+
const MentionNodeView = ({ node }) => {
|
|
7
|
+
const handle = node.attrs?.handle ?? "";
|
|
8
|
+
const label = node.attrs?.label ?? "";
|
|
9
|
+
return (
|
|
10
|
+
<NodeViewWrapper
|
|
11
|
+
as="span"
|
|
12
|
+
className="inline-flex items-center align-middle -translate-y-2"
|
|
13
|
+
>
|
|
14
|
+
<Mention handle={handle} size="small">
|
|
15
|
+
@{handle} · {label}
|
|
16
|
+
</Mention>
|
|
17
|
+
</NodeViewWrapper>
|
|
18
|
+
);
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const CommentMentionExtension = Node.create({
|
|
22
|
+
name: "commentMention",
|
|
23
|
+
group: "inline",
|
|
24
|
+
inline: true,
|
|
25
|
+
atom: true,
|
|
26
|
+
selectable: false,
|
|
27
|
+
|
|
28
|
+
addAttributes() {
|
|
29
|
+
return {
|
|
30
|
+
id: { default: null },
|
|
31
|
+
handle: { default: "" },
|
|
32
|
+
label: { default: "" },
|
|
33
|
+
};
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
parseHTML() {
|
|
37
|
+
return [{ tag: "span[data-comment-mention]" }];
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
renderHTML({ HTMLAttributes }) {
|
|
41
|
+
const handle = HTMLAttributes.handle || "";
|
|
42
|
+
const label = HTMLAttributes.label || `@${handle}`;
|
|
43
|
+
return [
|
|
44
|
+
"span",
|
|
45
|
+
mergeAttributes(HTMLAttributes, {
|
|
46
|
+
"data-comment-mention": "true",
|
|
47
|
+
"data-handle": handle,
|
|
48
|
+
}),
|
|
49
|
+
label,
|
|
50
|
+
];
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
addNodeView() {
|
|
54
|
+
return ReactNodeViewRenderer(MentionNodeView);
|
|
55
|
+
},
|
|
56
|
+
});
|
package/src/index.js
ADDED