@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,234 @@
1
+ import React, {
2
+ forwardRef,
3
+ useEffect,
4
+ useMemo,
5
+ useState,
6
+ createContext,
7
+ useContext,
8
+ isValidElement,
9
+ } from "react";
10
+ import { cx } from "@edux-design/utils";
11
+ import { Avatar } from "@edux-design/avatars";
12
+
13
+ const VARIANT_STYLES = {
14
+ default: "border-border-minimal bg-bg-base",
15
+ resolved: "border-border-success-base bg-bg-success-subtle",
16
+ };
17
+
18
+ const CommentContext = createContext({
19
+ editable: true,
20
+ onResolve: undefined,
21
+ });
22
+
23
+ const useCommentContext = () => useContext(CommentContext);
24
+
25
+ /**
26
+ * @typedef {React.HTMLAttributes<HTMLDivElement> & {
27
+ * author?: React.ReactNode,
28
+ * timestamp?: React.ReactNode,
29
+ * avatar?: React.ReactNode,
30
+ * avatarName?: string,
31
+ * avatarProps?: import("@edux-design/avatars").AvatarProps,
32
+ * body?: React.ReactNode,
33
+ * replies?: React.ReactNode,
34
+ * variant?: "default" | "resolved",
35
+ * resolved?: boolean,
36
+ * onResolve?: (event: React.MouseEvent<HTMLButtonElement>) => void,
37
+ * editable?: boolean,
38
+ * children?: React.ReactNode,
39
+ * }} CommentProps
40
+ */
41
+
42
+ /**
43
+ * Flexible comment surface with optional avatar, metadata, actions, and replies.
44
+ */
45
+ export const Comment = forwardRef(function Comment(
46
+ {
47
+ author = "Author",
48
+ timestamp,
49
+ avatar,
50
+ avatarName,
51
+ avatarProps,
52
+ body,
53
+ replies,
54
+ variant = "default",
55
+ resolved = false,
56
+ onResolve,
57
+ editable = true,
58
+ children,
59
+ className,
60
+ ...props
61
+ },
62
+ ref
63
+ ) {
64
+ const childArray = React.Children.toArray(children);
65
+ const actionsSlot = childArray.find(
66
+ (child) =>
67
+ isValidElement(child) &&
68
+ (child.type === CommentActions ||
69
+ child.type?.displayName === "Comment.Actions")
70
+ );
71
+ const bodyNodes = childArray.filter((child) => child !== actionsSlot);
72
+ const bodyContent = body ?? bodyNodes;
73
+ const hasBodyContent =
74
+ body !== undefined ? Boolean(body) : bodyNodes.length > 0;
75
+ const isResolved = resolved || variant === "resolved";
76
+ const resolvedVariant =
77
+ VARIANT_STYLES[isResolved ? "resolved" : variant] ?? VARIANT_STYLES.default;
78
+ const resolvedAvatarName =
79
+ avatarName ?? (typeof author === "string" ? author : undefined);
80
+ const shouldRenderAvatar = Boolean(avatar || avatarProps || resolvedAvatarName);
81
+ const replyItems = useMemo(
82
+ () =>
83
+ React.Children.toArray(replies).filter(
84
+ (reply) => reply !== null && reply !== undefined && reply !== false
85
+ ),
86
+ [replies]
87
+ );
88
+ const replyCount = replyItems.length;
89
+ const shouldCollapseReplies = replyCount > 2;
90
+ const [showAllReplies, setShowAllReplies] = useState(!shouldCollapseReplies);
91
+
92
+ useEffect(() => {
93
+ setShowAllReplies(!shouldCollapseReplies);
94
+ }, [shouldCollapseReplies]);
95
+ const visibleReplies =
96
+ shouldCollapseReplies && !showAllReplies
97
+ ? [replyItems[replyCount - 1]]
98
+ : replyItems;
99
+
100
+ return (
101
+ <CommentContext.Provider value={{ editable, onResolve }}>
102
+ <div
103
+ ref={ref}
104
+ className={cx(
105
+ "flex gap-12 rounded-default border p-12",
106
+ resolvedVariant,
107
+ className
108
+ )}
109
+ data-variant={variant}
110
+ {...props}
111
+ >
112
+ {shouldRenderAvatar && (
113
+ <div className="shrink-0">
114
+ {avatar ?? (
115
+ <Avatar name={resolvedAvatarName} size="small" {...avatarProps} />
116
+ )}
117
+ </div>
118
+ )}
119
+
120
+ <div className="min-w-0 flex-1">
121
+ <div className="flex flex-wrap items-center gap-6">
122
+ <span className="truncate text-sm font-semibold text-fg-base">
123
+ {author}
124
+ </span>
125
+ {timestamp && (
126
+ <span className="text-xs text-fg-subtle">{timestamp}</span>
127
+ )}
128
+ </div>
129
+
130
+ {hasBodyContent && !isResolved ? (
131
+ <div className="mt-6 text-sm leading-relaxed text-fg-base">
132
+ {bodyContent}
133
+ </div>
134
+ ) : null}
135
+
136
+ {isResolved ? (
137
+ <div className="mt-6 text-xs font-semibold uppercase tracking-wide text-fg-success-base">
138
+ Resolved
139
+ </div>
140
+ ) : null}
141
+
142
+ {actionsSlot ? (
143
+ <div className="mt-8">{actionsSlot}</div>
144
+ ) : null}
145
+
146
+ {replies && !isResolved ? (
147
+ <div className="mt-10 border-l border-border-minimal pl-12">
148
+ {shouldCollapseReplies && !showAllReplies && (
149
+ <button
150
+ type="button"
151
+ className="mb-8 text-xs font-semibold text-fg-info-base"
152
+ onClick={() => setShowAllReplies(true)}
153
+ aria-expanded={showAllReplies}
154
+ >
155
+ Show all {replyCount} replies
156
+ </button>
157
+ )}
158
+ {visibleReplies}
159
+ </div>
160
+ ) : null}
161
+ </div>
162
+ </div>
163
+ </CommentContext.Provider>
164
+ );
165
+ });
166
+
167
+ export const CommentActions = ({ children, className, ...props }) => (
168
+ <div
169
+ className={cx(
170
+ "flex flex-wrap items-center gap-8 text-xs text-fg-subtle",
171
+ className
172
+ )}
173
+ {...props}
174
+ >
175
+ {children}
176
+ </div>
177
+ );
178
+
179
+ export const CommentReply = ({ className, ...props }) => (
180
+ <button
181
+ type="button"
182
+ className={cx("text-xs text-fg-info-base cursor-pointer", className)}
183
+ {...props}
184
+ >
185
+ Reply
186
+ </button>
187
+ );
188
+
189
+ export const CommentEdit = ({ className, ...props }) => (
190
+ <button
191
+ type="button"
192
+ className={cx("text-xs text-fg-info-base cursor-pointer", className)}
193
+ {...props}
194
+ >
195
+ Edit
196
+ </button>
197
+ );
198
+
199
+ export const CommentResolve = ({
200
+ className,
201
+ disabled,
202
+ onClick,
203
+ ...props
204
+ }) => {
205
+ const { editable, onResolve } = useCommentContext();
206
+ const isDisabled = disabled || !editable;
207
+ const handleClick = onClick ?? onResolve;
208
+
209
+ return (
210
+ <button
211
+ type="button"
212
+ className={cx(
213
+ "text-xs font-semibold text-fg-success-base cursor-pointer",
214
+ className
215
+ )}
216
+ onClick={handleClick}
217
+ disabled={isDisabled}
218
+ aria-disabled={isDisabled}
219
+ {...props}
220
+ >
221
+ Resolve
222
+ </button>
223
+ );
224
+ };
225
+
226
+ Comment.Actions = CommentActions;
227
+ Comment.Reply = CommentReply;
228
+ Comment.Edit = CommentEdit;
229
+ Comment.Resolve = CommentResolve;
230
+
231
+ CommentActions.displayName = "Comment.Actions";
232
+ CommentReply.displayName = "Comment.Reply";
233
+ CommentEdit.displayName = "Comment.Edit";
234
+ CommentResolve.displayName = "Comment.Resolve";
@@ -0,0 +1,137 @@
1
+ import React, { createRef } 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
+ vi.mock("@edux-design/utils", () => ({
7
+ cx: (...args) => args.filter(Boolean).join(" "),
8
+ }));
9
+
10
+ vi.mock("@edux-design/avatars", () => ({
11
+ Avatar: (props) => (
12
+ <div data-testid="avatar" data-name={props.name} data-size={props.size} />
13
+ ),
14
+ }));
15
+
16
+ import { Comment } from "./Comment";
17
+
18
+ afterEach(() => {
19
+ cleanup();
20
+ vi.restoreAllMocks();
21
+ });
22
+
23
+ describe("Comment", () => {
24
+ it("renders the avatar using the Avatar package by default", () => {
25
+ render(<Comment author="Alex Johnson" body="Hello" />);
26
+
27
+ const avatar = screen.getByTestId("avatar");
28
+ expect(avatar).toBeInTheDocument();
29
+ expect(avatar).toHaveAttribute("data-name", "Alex Johnson");
30
+ });
31
+
32
+ it("uses the provided avatar node when supplied", () => {
33
+ render(
34
+ <Comment
35
+ author="Alex"
36
+ body="Hi"
37
+ avatar={<span data-testid="custom-avatar" />}
38
+ />
39
+ );
40
+
41
+ expect(screen.getByTestId("custom-avatar")).toBeInTheDocument();
42
+ expect(screen.queryByTestId("avatar")).not.toBeInTheDocument();
43
+ });
44
+
45
+ it("renders actions below the body content", () => {
46
+ render(
47
+ <Comment author="Alex" body="Body copy">
48
+ <Comment.Actions>
49
+ <Comment.Reply>Reply</Comment.Reply>
50
+ </Comment.Actions>
51
+ </Comment>
52
+ );
53
+
54
+ const body = screen.getByText("Body copy");
55
+ const actions = screen.getByRole("button", { name: "Reply" });
56
+
57
+ expect(body.compareDocumentPosition(actions)).toBe(
58
+ Node.DOCUMENT_POSITION_FOLLOWING
59
+ );
60
+ });
61
+
62
+ it("renders nested replies", () => {
63
+ render(
64
+ <Comment
65
+ author="Alex"
66
+ body="Root"
67
+ replies={<Comment author="Jamie" body="Nested" />}
68
+ />
69
+ );
70
+
71
+ expect(screen.getByText("Root")).toBeInTheDocument();
72
+ expect(screen.getByText("Nested")).toBeInTheDocument();
73
+ });
74
+
75
+ it("shows a show-all control and expands to reveal all replies", () => {
76
+ render(
77
+ <Comment
78
+ author="Alex"
79
+ body="Root"
80
+ replies={
81
+ <>
82
+ <Comment author="Jamie" body="First reply" />
83
+ <Comment author="Riley" body="Second reply" />
84
+ <Comment author="Casey" body="Last reply" />
85
+ </>
86
+ }
87
+ />
88
+ );
89
+
90
+ expect(screen.getByText("Show all 3 replies")).toBeInTheDocument();
91
+ expect(screen.queryByText("First reply")).not.toBeInTheDocument();
92
+ expect(screen.queryByText("Second reply")).not.toBeInTheDocument();
93
+ expect(screen.getByText("Last reply")).toBeInTheDocument();
94
+
95
+ fireEvent.click(screen.getByText("Show all 3 replies"));
96
+
97
+ expect(screen.getByText("First reply")).toBeInTheDocument();
98
+ expect(screen.getByText("Second reply")).toBeInTheDocument();
99
+ expect(screen.getByText("Last reply")).toBeInTheDocument();
100
+ });
101
+
102
+ it("applies the resolved variant", () => {
103
+ const { container } = render(
104
+ <Comment author="Alex" body="Resolved" variant="resolved" />
105
+ );
106
+
107
+ expect(container.firstChild).toHaveAttribute("data-variant", "resolved");
108
+ expect(container.firstChild).toHaveClass("border-border-success-base");
109
+ });
110
+
111
+ it("shows resolved state and hides body when resolved", () => {
112
+ render(<Comment author="Alex" body="Hidden body" resolved />);
113
+ expect(screen.getByText("Resolved")).toBeInTheDocument();
114
+ expect(screen.queryByText("Hidden body")).not.toBeInTheDocument();
115
+ });
116
+
117
+ it("fires onResolve when resolve button is clicked", () => {
118
+ const handleResolve = vi.fn();
119
+ render(
120
+ <Comment author="Alex" body="Body" onResolve={handleResolve}>
121
+ <Comment.Actions>
122
+ <Comment.Resolve />
123
+ </Comment.Actions>
124
+ </Comment>
125
+ );
126
+
127
+ fireEvent.click(screen.getByRole("button", { name: "Resolve" }));
128
+ expect(handleResolve).toHaveBeenCalledTimes(1);
129
+ });
130
+
131
+ it("forwards the ref to the root element", () => {
132
+ const ref = createRef();
133
+ render(<Comment ref={ref} author="Alex" body="Ref" />);
134
+ expect(ref.current).toBeInstanceOf(HTMLElement);
135
+ expect(ref.current.tagName.toLowerCase()).toBe("div");
136
+ });
137
+ });
@@ -0,0 +1,184 @@
1
+ import React, {
2
+ forwardRef,
3
+ useMemo,
4
+ useRef,
5
+ createContext,
6
+ useContext,
7
+ useEffect,
8
+ } from "react";
9
+ import { cx } from "@edux-design/utils";
10
+ import { Avatar } from "@edux-design/avatars";
11
+ import { Button } from "@edux-design/buttons";
12
+ import { useEditor, EditorContent } from "@tiptap/react";
13
+ import StarterKit from "@tiptap/starter-kit";
14
+ import Placeholder from "@tiptap/extension-placeholder";
15
+ import { CommentMentionExtension } from "./CommentMentionExtension";
16
+
17
+ const CommentComposerContext = createContext(null);
18
+
19
+ export const useCommentComposer = () => {
20
+ const context = useContext(CommentComposerContext);
21
+ if (!context) {
22
+ throw new Error(
23
+ "CommentComposer components must be used within a <CommentComposer />."
24
+ );
25
+ }
26
+ return context;
27
+ };
28
+
29
+ /**
30
+ * @typedef {React.FormHTMLAttributes<HTMLFormElement> & {
31
+ * value?: string,
32
+ * onChange?: (event: React.ChangeEvent<HTMLTextAreaElement>) => void,
33
+ * onSubmit?: (event: React.FormEvent<HTMLFormElement>) => void,
34
+ * onCancel?: (event: React.MouseEvent<HTMLButtonElement>) => void,
35
+ * placeholder?: string,
36
+ * rows?: number,
37
+ * disabled?: boolean,
38
+ * isSubmitting?: boolean,
39
+ * submitLabel?: string,
40
+ * cancelLabel?: string,
41
+ * avatar?: React.ReactNode,
42
+ * avatarProps?: import("@edux-design/avatars").AvatarProps,
43
+ * actions?: React.ReactNode,
44
+ * children?: React.ReactNode,
45
+ * }} CommentComposerProps
46
+ */
47
+
48
+ /**
49
+ * Comment composer with avatar, textarea, and action buttons.
50
+ */
51
+ export const CommentComposer = forwardRef(function CommentComposer(
52
+ {
53
+ value,
54
+ onChange,
55
+ onSubmit,
56
+ onCancel,
57
+ placeholder = "Write a comment...",
58
+ rows = 4,
59
+ disabled = false,
60
+ isSubmitting = false,
61
+ submitLabel = "Post",
62
+ cancelLabel = "Cancel",
63
+ avatar,
64
+ avatarProps,
65
+ actions,
66
+ className,
67
+ children,
68
+ ...props
69
+ },
70
+ ref
71
+ ) {
72
+ const hasValue =
73
+ value === undefined ? true : Boolean(value && value.trim().length);
74
+ const isSubmitDisabled = disabled || isSubmitting || !hasValue;
75
+ const editor = useEditor({
76
+ extensions: [
77
+ StarterKit,
78
+ CommentMentionExtension,
79
+ Placeholder.configure({
80
+ placeholder,
81
+ }),
82
+ ],
83
+ content: value ?? "",
84
+ editable: !disabled,
85
+ editorProps: {
86
+ attributes: {
87
+ class: cx(
88
+ "w-full bg-white border-2 rounded-md border-border-base",
89
+ "outline-none transition-all duration-300 ease-in-out",
90
+ "pl-5 py-3 font-normal text-base leading-relaxed",
91
+ "h-[120px] overflow-y-auto",
92
+ "focus:shadow-focus focus-visible:shadow-focus",
93
+ disabled && "bg-bg-subtle border-border-subtle text-fg-inactive"
94
+ ),
95
+ },
96
+ },
97
+ onUpdate: ({ editor }) => {
98
+ onChange?.({ target: { value: editor.getText() } });
99
+ },
100
+ });
101
+
102
+ const containerRef = useRef(null);
103
+
104
+ useEffect(() => {
105
+ if (typeof ref === "function") {
106
+ ref(containerRef.current);
107
+ } else if (ref) {
108
+ ref.current = containerRef.current;
109
+ }
110
+ }, [ref]);
111
+
112
+ useEffect(() => {
113
+ if (!editor) return;
114
+ if (value === "") {
115
+ editor.commands.clearContent();
116
+ }
117
+ }, [editor, value]);
118
+
119
+ const contextValue = useMemo(
120
+ () => ({
121
+ editor,
122
+ placeholder,
123
+ editorContainerRef: containerRef,
124
+ }),
125
+ [editor, placeholder]
126
+ );
127
+
128
+ return (
129
+ <CommentComposerContext.Provider value={contextValue}>
130
+ <form
131
+ className={cx(
132
+ "relative flex gap-12 rounded-default border border-border-minimal bg-bg-base p-12",
133
+ className
134
+ )}
135
+ onSubmit={(event) => {
136
+ event.preventDefault();
137
+ if (isSubmitDisabled) return;
138
+ onSubmit?.(event);
139
+ }}
140
+ {...props}
141
+ >
142
+ {(avatar || avatarProps) && (
143
+ <div className="shrink-0">
144
+ {avatar ?? <Avatar size="small" {...avatarProps} />}
145
+ </div>
146
+ )}
147
+
148
+ <div className="min-w-0 flex-1">
149
+ <div ref={containerRef} className="relative">
150
+ <EditorContent editor={editor} />
151
+ </div>
152
+
153
+ <div className="mt-8 flex flex-wrap items-center justify-between gap-8">
154
+ {actions ? (
155
+ <div className="flex flex-wrap items-center gap-8 text-xs text-fg-subtle">
156
+ {actions}
157
+ </div>
158
+ ) : (
159
+ <span />
160
+ )}
161
+
162
+ <div className="flex items-center gap-8">
163
+ {onCancel && (
164
+ <Button
165
+ type="button"
166
+ size="small"
167
+ isSecondary
168
+ onClick={onCancel}
169
+ disabled={disabled || isSubmitting}
170
+ >
171
+ {cancelLabel}
172
+ </Button>
173
+ )}
174
+ <Button type="submit" size="small" disabled={isSubmitDisabled}>
175
+ {isSubmitting ? "Posting..." : submitLabel}
176
+ </Button>
177
+ </div>
178
+ </div>
179
+ </div>
180
+ {children}
181
+ </form>
182
+ </CommentComposerContext.Provider>
183
+ );
184
+ });
@@ -0,0 +1,125 @@
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
+ vi.mock("@edux-design/utils", () => ({
7
+ cx: (...args) => args.filter(Boolean).join(" "),
8
+ }));
9
+
10
+ vi.mock("@edux-design/avatars", () => ({
11
+ Avatar: (props) => (
12
+ <div data-testid="avatar" data-name={props.name} data-size={props.size} />
13
+ ),
14
+ }));
15
+
16
+ vi.mock("@edux-design/buttons", () => ({
17
+ Button: ({ children, ...props }) => <button {...props}>{children}</button>,
18
+ }));
19
+
20
+ const mockEditor = {
21
+ isEmpty: true,
22
+ getText: () => "",
23
+ on: vi.fn(),
24
+ off: vi.fn(),
25
+ commands: {
26
+ clearContent: vi.fn(),
27
+ },
28
+ state: {
29
+ selection: {
30
+ from: 0,
31
+ $from: {
32
+ parent: { isTextblock: true, textBetween: () => "" },
33
+ parentOffset: 0,
34
+ pos: 0,
35
+ },
36
+ },
37
+ },
38
+ chain: () => ({
39
+ focus: () => ({
40
+ deleteRange: () => ({
41
+ insertContent: () => ({ run: vi.fn() }),
42
+ }),
43
+ }),
44
+ }),
45
+ };
46
+
47
+ vi.mock("@tiptap/react", async () => {
48
+ const React = await import("react");
49
+ return {
50
+ useEditor: vi.fn(() => mockEditor),
51
+ EditorContent: () => <div data-testid="editor" />,
52
+ ReactNodeViewRenderer: (Component) => Component,
53
+ NodeViewWrapper: ({ children }) => <span>{children}</span>,
54
+ };
55
+ });
56
+
57
+ vi.mock("@tiptap/starter-kit", () => ({
58
+ default: {},
59
+ }));
60
+
61
+ vi.mock("@tiptap/extension-placeholder", () => ({
62
+ default: {
63
+ configure: () => ({}),
64
+ },
65
+ }));
66
+
67
+ vi.mock("@tiptap/core", () => ({
68
+ Node: {
69
+ create: (config) => config,
70
+ },
71
+ mergeAttributes: (...args) => Object.assign({}, ...args),
72
+ }));
73
+
74
+ import { CommentComposer } from "./CommentComposer";
75
+
76
+ afterEach(() => {
77
+ cleanup();
78
+ vi.restoreAllMocks();
79
+ });
80
+
81
+ describe("CommentComposer", () => {
82
+ it("renders avatar and editor", () => {
83
+ render(
84
+ <CommentComposer
85
+ value=""
86
+ onChange={() => {}}
87
+ avatarProps={{ name: "Alex" }}
88
+ />
89
+ );
90
+
91
+ expect(screen.getByTestId("avatar")).toBeInTheDocument();
92
+ expect(screen.getByTestId("editor")).toBeInTheDocument();
93
+ });
94
+
95
+ it("disables submit when value is empty", () => {
96
+ render(<CommentComposer value="" onChange={() => {}} />);
97
+
98
+ const submitButton = screen.getByRole("button", { name: "Post" });
99
+ expect(submitButton).toBeDisabled();
100
+ });
101
+
102
+ it("submits when value is present", () => {
103
+ const handleSubmit = vi.fn();
104
+ render(
105
+ <CommentComposer value="Hello" onChange={() => {}} onSubmit={handleSubmit} />
106
+ );
107
+
108
+ fireEvent.click(screen.getByRole("button", { name: "Post" }));
109
+ expect(handleSubmit).toHaveBeenCalledTimes(1);
110
+ });
111
+
112
+ it("calls onCancel when cancel button is clicked", () => {
113
+ const handleCancel = vi.fn();
114
+ render(
115
+ <CommentComposer
116
+ value="Hello"
117
+ onChange={() => {}}
118
+ onCancel={handleCancel}
119
+ />
120
+ );
121
+
122
+ fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
123
+ expect(handleCancel).toHaveBeenCalledTimes(1);
124
+ });
125
+ });