@edux-design/comments 0.1.4 → 0.2.0

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.
@@ -1,240 +0,0 @@
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
- resolved: false,
22
- });
23
-
24
- const useCommentContext = () => useContext(CommentContext);
25
-
26
- /**
27
- * @typedef {React.HTMLAttributes<HTMLDivElement> & {
28
- * author?: React.ReactNode,
29
- * timestamp?: React.ReactNode,
30
- * avatar?: React.ReactNode,
31
- * avatarName?: string,
32
- * avatarProps?: import("@edux-design/avatars").AvatarProps,
33
- * body?: React.ReactNode,
34
- * replies?: React.ReactNode,
35
- * variant?: "default" | "resolved",
36
- * resolved?: boolean,
37
- * onResolve?: (event: React.MouseEvent<HTMLButtonElement>) => void,
38
- * editable?: boolean,
39
- * children?: React.ReactNode,
40
- * }} CommentProps
41
- */
42
-
43
- /**
44
- * Flexible comment surface with optional avatar, metadata, actions, and replies.
45
- */
46
- export const Comment = forwardRef(function Comment(
47
- {
48
- author = "Author",
49
- timestamp,
50
- avatar,
51
- avatarName,
52
- avatarProps,
53
- body,
54
- replies,
55
- variant = "default",
56
- resolved = false,
57
- onResolve,
58
- editable = true,
59
- children,
60
- className,
61
- ...props
62
- },
63
- ref
64
- ) {
65
- const childArray = React.Children.toArray(children);
66
- const actionsSlot = childArray.find(
67
- (child) =>
68
- isValidElement(child) &&
69
- (child.type === CommentActions ||
70
- child.type?.displayName === "Comment.Actions")
71
- );
72
- const bodyNodes = childArray.filter((child) => child !== actionsSlot);
73
- const bodyContent = body ?? bodyNodes;
74
- const hasBodyContent =
75
- body !== undefined ? Boolean(body) : bodyNodes.length > 0;
76
- const isResolved = resolved || variant === "resolved";
77
- const resolvedVariant =
78
- VARIANT_STYLES[isResolved ? "resolved" : variant] ?? VARIANT_STYLES.default;
79
- const resolvedAvatarName =
80
- avatarName ?? (typeof author === "string" ? author : undefined);
81
- const shouldRenderAvatar = Boolean(avatar || avatarProps || resolvedAvatarName);
82
- const replyItems = useMemo(
83
- () =>
84
- React.Children.toArray(replies).filter(
85
- (reply) => reply !== null && reply !== undefined && reply !== false
86
- ),
87
- [replies]
88
- );
89
- const replyCount = replyItems.length;
90
- const shouldCollapseReplies = replyCount > 2;
91
- const [showAllReplies, setShowAllReplies] = useState(!shouldCollapseReplies);
92
-
93
- useEffect(() => {
94
- setShowAllReplies(!shouldCollapseReplies);
95
- }, [shouldCollapseReplies]);
96
- const visibleReplies =
97
- shouldCollapseReplies && !showAllReplies
98
- ? [replyItems[replyCount - 1]]
99
- : replyItems;
100
-
101
- return (
102
- <CommentContext.Provider value={{ editable, onResolve, resolved: isResolved }}>
103
- <div
104
- ref={ref}
105
- className={cx(
106
- "flex gap-12 rounded-default border p-12",
107
- resolvedVariant,
108
- className
109
- )}
110
- data-variant={variant}
111
- {...props}
112
- >
113
- {shouldRenderAvatar && (
114
- <div className="shrink-0">
115
- {avatar ?? (
116
- <Avatar name={resolvedAvatarName} size="small" {...avatarProps} />
117
- )}
118
- </div>
119
- )}
120
-
121
- <div className="min-w-0 flex-1">
122
- <div className="flex flex-wrap items-center gap-6">
123
- <span className="truncate text-sm font-semibold text-fg-base">
124
- {author}
125
- </span>
126
- {timestamp && (
127
- <span className="text-xs text-fg-subtle">{timestamp}</span>
128
- )}
129
- </div>
130
-
131
- {hasBodyContent && !isResolved ? (
132
- <div className="mt-6 text-sm leading-relaxed text-fg-base">
133
- {bodyContent}
134
- </div>
135
- ) : null}
136
-
137
- {isResolved ? (
138
- <div className="mt-6 text-xs font-semibold uppercase tracking-wide text-fg-success-base">
139
- Resolved
140
- </div>
141
- ) : null}
142
-
143
- {actionsSlot ? (
144
- <div className="mt-8">{actionsSlot}</div>
145
- ) : null}
146
-
147
- {replies && !isResolved ? (
148
- <div className="mt-10 border-l border-border-minimal pl-12">
149
- {shouldCollapseReplies && !showAllReplies && (
150
- <button
151
- type="button"
152
- className="mb-8 text-xs font-semibold text-fg-info-base"
153
- onClick={() => setShowAllReplies(true)}
154
- aria-expanded={showAllReplies}
155
- >
156
- Show all {replyCount} replies
157
- </button>
158
- )}
159
- {visibleReplies}
160
- </div>
161
- ) : null}
162
- </div>
163
- </div>
164
- </CommentContext.Provider>
165
- );
166
- });
167
-
168
- export const CommentActions = ({ children, className, ...props }) => (
169
- <div
170
- className={cx(
171
- "flex flex-wrap items-center gap-8 text-xs text-fg-subtle",
172
- className
173
- )}
174
- {...props}
175
- >
176
- {children}
177
- </div>
178
- );
179
-
180
- export const CommentReply = ({ className, ...props }) => (
181
- <button
182
- type="button"
183
- className={cx("text-xs text-fg-info-base cursor-pointer", className)}
184
- {...props}
185
- >
186
- Reply
187
- </button>
188
- );
189
-
190
- export const CommentEdit = ({ className, ...props }) => (
191
- <button
192
- type="button"
193
- className={cx("text-xs text-fg-info-base cursor-pointer", className)}
194
- {...props}
195
- >
196
- Edit
197
- </button>
198
- );
199
-
200
- export const CommentResolve = ({
201
- className,
202
- disabled,
203
- onClick,
204
- label,
205
- ...props
206
- }) => {
207
- const { editable, onResolve, resolved } = useCommentContext();
208
- const isDisabled = disabled || !editable;
209
- const handleClick = onClick ?? onResolve;
210
- const isResolved = Boolean(resolved);
211
- const actionLabel = label ?? (isResolved ? "Re-open" : "Resolve");
212
- const toneClass = isResolved ? "text-fg-info-base" : "text-fg-success-base";
213
-
214
- return (
215
- <button
216
- type="button"
217
- className={cx(
218
- "text-xs font-semibold cursor-pointer",
219
- toneClass,
220
- className
221
- )}
222
- onClick={handleClick}
223
- disabled={isDisabled}
224
- aria-disabled={isDisabled}
225
- {...props}
226
- >
227
- {actionLabel}
228
- </button>
229
- );
230
- };
231
-
232
- Comment.Actions = CommentActions;
233
- Comment.Reply = CommentReply;
234
- Comment.Edit = CommentEdit;
235
- Comment.Resolve = CommentResolve;
236
-
237
- CommentActions.displayName = "Comment.Actions";
238
- CommentReply.displayName = "Comment.Reply";
239
- CommentEdit.displayName = "Comment.Edit";
240
- CommentResolve.displayName = "Comment.Resolve";
@@ -1,151 +0,0 @@
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("renders a re-open resolve action when resolved", () => {
118
- render(
119
- <Comment author="Alex" body="Body" resolved>
120
- <Comment.Actions>
121
- <Comment.Resolve />
122
- </Comment.Actions>
123
- </Comment>
124
- );
125
-
126
- const resolveButton = screen.getByRole("button", { name: "Re-open" });
127
- expect(resolveButton).toBeInTheDocument();
128
- expect(resolveButton).toHaveClass("text-fg-info-base");
129
- });
130
-
131
- it("fires onResolve when resolve button is clicked", () => {
132
- const handleResolve = vi.fn();
133
- render(
134
- <Comment author="Alex" body="Body" onResolve={handleResolve}>
135
- <Comment.Actions>
136
- <Comment.Resolve />
137
- </Comment.Actions>
138
- </Comment>
139
- );
140
-
141
- fireEvent.click(screen.getByRole("button", { name: "Resolve" }));
142
- expect(handleResolve).toHaveBeenCalledTimes(1);
143
- });
144
-
145
- it("forwards the ref to the root element", () => {
146
- const ref = createRef();
147
- render(<Comment ref={ref} author="Alex" body="Ref" />);
148
- expect(ref.current).toBeInstanceOf(HTMLElement);
149
- expect(ref.current.tagName.toLowerCase()).toBe("div");
150
- });
151
- });
@@ -1,184 +0,0 @@
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
- });
@@ -1,125 +0,0 @@
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
- });