@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.
@@ -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
@@ -0,0 +1,3 @@
1
+ export { Comment } from "./elements/Comment";
2
+ export { CommentComposer } from "./elements/CommentComposer";
3
+ export { CommentMention } from "./elements/CommentMention";
package/tsconfig.json ADDED
@@ -0,0 +1,10 @@
1
+ {
2
+ "compilerOptions": {
3
+ "allowJs": true,
4
+ "checkJs": false,
5
+ "declaration": true,
6
+ "emitDeclarationOnly": true,
7
+ "jsx": "react-jsx",
8
+ "outDir": "dist"
9
+ }
10
+ }