@edux-design/comments 0.1.3 → 0.1.4
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 +6 -0
- package/README.md +46 -4
- package/dist/index.d.mts +2 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +28 -8
- package/dist/index.mjs +28 -8
- package/package.json +1 -1
- package/src/demos/Comment.stories.jsx +126 -5
- package/src/demos/CommentComposer.stories.jsx +5 -1
- package/src/elements/Comment.jsx +10 -4
- package/src/elements/Comment.test.jsx +14 -0
- package/src/elements/CommentMention.jsx +17 -2
- package/src/elements/CommentMention.test.jsx +12 -0
package/CHANGELOG.md
CHANGED
package/README.md
CHANGED
|
@@ -13,17 +13,19 @@ npm i @edux-design/comments @edux-design/avatars
|
|
|
13
13
|
```jsx
|
|
14
14
|
import { Comment } from "@edux-design/comments";
|
|
15
15
|
import { CommentComposer } from "@edux-design/comments";
|
|
16
|
+
import { CommentMention } from "@edux-design/comments";
|
|
16
17
|
|
|
17
18
|
export function Example() {
|
|
19
|
+
const [resolved, setResolved] = React.useState(false);
|
|
20
|
+
|
|
18
21
|
return (
|
|
19
22
|
<Comment
|
|
20
23
|
author="Alex Johnson"
|
|
21
24
|
timestamp="2h ago"
|
|
22
25
|
body="Left a few notes on the introduction. Let me know if you want me to draft the edits."
|
|
23
26
|
avatarProps={{ name: "Alex Johnson" }}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
}}
|
|
27
|
+
resolved={resolved}
|
|
28
|
+
onResolve={() => setResolved((prev) => !prev)}
|
|
27
29
|
replies={
|
|
28
30
|
<Comment
|
|
29
31
|
author="Taylor"
|
|
@@ -47,6 +49,7 @@ export function Example() {
|
|
|
47
49
|
```jsx
|
|
48
50
|
export function ComposerExample() {
|
|
49
51
|
const [value, setValue] = React.useState("");
|
|
52
|
+
const [query, setQuery] = React.useState("");
|
|
50
53
|
|
|
51
54
|
return (
|
|
52
55
|
<CommentComposer
|
|
@@ -55,7 +58,46 @@ export function ComposerExample() {
|
|
|
55
58
|
onSubmit={() => setValue("")}
|
|
56
59
|
onCancel={() => setValue("")}
|
|
57
60
|
avatarProps={{ name: "Alex Johnson" }}
|
|
58
|
-
|
|
61
|
+
>
|
|
62
|
+
<CommentMention
|
|
63
|
+
peopleList={[
|
|
64
|
+
{ id: "alex", name: "Alex Johnson", handle: "alex" },
|
|
65
|
+
{ id: "priya", name: "Priya Singh", handle: "priya" },
|
|
66
|
+
]}
|
|
67
|
+
onQueryChange={setQuery}
|
|
68
|
+
/>
|
|
69
|
+
<div className="mt-8 text-xs text-fg-subtle">
|
|
70
|
+
Mention query: <span className="font-semibold">{query || "—"}</span>
|
|
71
|
+
</div>
|
|
72
|
+
</CommentComposer>
|
|
59
73
|
);
|
|
60
74
|
}
|
|
61
75
|
```
|
|
76
|
+
|
|
77
|
+
## Mentions
|
|
78
|
+
|
|
79
|
+
`CommentMention` handles the in-editor mention menu and emits the active query via
|
|
80
|
+
`onQueryChange` when the user types `@query` at the cursor. Debounce behavior is
|
|
81
|
+
left to the consumer.
|
|
82
|
+
|
|
83
|
+
```jsx
|
|
84
|
+
import { CommentComposer, CommentMention } from "@edux-design/comments";
|
|
85
|
+
|
|
86
|
+
<CommentComposer value={value} onChange={(event) => setValue(event.target.value)}>
|
|
87
|
+
<CommentMention peopleList={people} onQueryChange={setQuery} />
|
|
88
|
+
</CommentComposer>;
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Resolve / Re-open
|
|
92
|
+
|
|
93
|
+
`Comment.Resolve` swaps label and styling based on the resolved state. You can
|
|
94
|
+
override the label if needed.
|
|
95
|
+
|
|
96
|
+
```jsx
|
|
97
|
+
<Comment resolved={isResolved} onResolve={toggleResolved}>
|
|
98
|
+
<Comment.Actions>
|
|
99
|
+
<Comment.Resolve />
|
|
100
|
+
<Comment.Resolve label="Re-open thread" />
|
|
101
|
+
</Comment.Actions>
|
|
102
|
+
</Comment>
|
|
103
|
+
```
|
package/dist/index.d.mts
CHANGED
|
@@ -45,8 +45,9 @@ declare const Comment: react.ForwardRefExoticComponent<react.RefAttributes<any>>
|
|
|
45
45
|
*/
|
|
46
46
|
declare const CommentComposer: react.ForwardRefExoticComponent<react.RefAttributes<any>>;
|
|
47
47
|
|
|
48
|
-
declare function CommentMention({ peopleList, }: {
|
|
48
|
+
declare function CommentMention({ peopleList, onQueryChange, }: {
|
|
49
49
|
peopleList?: any[];
|
|
50
|
+
onQueryChange: any;
|
|
50
51
|
}): react_jsx_runtime.JSX.Element;
|
|
51
52
|
|
|
52
53
|
export { Comment, CommentComposer, CommentMention };
|
package/dist/index.d.ts
CHANGED
|
@@ -45,8 +45,9 @@ declare const Comment: react.ForwardRefExoticComponent<react.RefAttributes<any>>
|
|
|
45
45
|
*/
|
|
46
46
|
declare const CommentComposer: react.ForwardRefExoticComponent<react.RefAttributes<any>>;
|
|
47
47
|
|
|
48
|
-
declare function CommentMention({ peopleList, }: {
|
|
48
|
+
declare function CommentMention({ peopleList, onQueryChange, }: {
|
|
49
49
|
peopleList?: any[];
|
|
50
|
+
onQueryChange: any;
|
|
50
51
|
}): react_jsx_runtime.JSX.Element;
|
|
51
52
|
|
|
52
53
|
export { Comment, CommentComposer, CommentMention };
|
package/dist/index.js
CHANGED
|
@@ -46,7 +46,8 @@ var VARIANT_STYLES = {
|
|
|
46
46
|
};
|
|
47
47
|
var CommentContext = (0, import_react.createContext)({
|
|
48
48
|
editable: true,
|
|
49
|
-
onResolve: void 0
|
|
49
|
+
onResolve: void 0,
|
|
50
|
+
resolved: false
|
|
50
51
|
});
|
|
51
52
|
var useCommentContext = () => (0, import_react.useContext)(CommentContext);
|
|
52
53
|
var Comment = (0, import_react.forwardRef)(function Comment2({
|
|
@@ -92,7 +93,7 @@ var Comment = (0, import_react.forwardRef)(function Comment2({
|
|
|
92
93
|
setShowAllReplies(!shouldCollapseReplies);
|
|
93
94
|
}, [shouldCollapseReplies]);
|
|
94
95
|
const visibleReplies = shouldCollapseReplies && !showAllReplies ? [replyItems[replyCount - 1]] : replyItems;
|
|
95
|
-
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(CommentContext.Provider, { value: { editable, onResolve }, children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
|
|
96
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(CommentContext.Provider, { value: { editable, onResolve, resolved: isResolved }, children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
|
|
96
97
|
"div",
|
|
97
98
|
{
|
|
98
99
|
ref,
|
|
@@ -168,24 +169,29 @@ var CommentResolve = ({
|
|
|
168
169
|
className,
|
|
169
170
|
disabled,
|
|
170
171
|
onClick,
|
|
172
|
+
label,
|
|
171
173
|
...props
|
|
172
174
|
}) => {
|
|
173
|
-
const { editable, onResolve } = useCommentContext();
|
|
175
|
+
const { editable, onResolve, resolved } = useCommentContext();
|
|
174
176
|
const isDisabled = disabled || !editable;
|
|
175
177
|
const handleClick = onClick ?? onResolve;
|
|
178
|
+
const isResolved = Boolean(resolved);
|
|
179
|
+
const actionLabel = label ?? (isResolved ? "Re-open" : "Resolve");
|
|
180
|
+
const toneClass = isResolved ? "text-fg-info-base" : "text-fg-success-base";
|
|
176
181
|
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
177
182
|
"button",
|
|
178
183
|
{
|
|
179
184
|
type: "button",
|
|
180
185
|
className: (0, import_utils.cx)(
|
|
181
|
-
"text-xs font-semibold
|
|
186
|
+
"text-xs font-semibold cursor-pointer",
|
|
187
|
+
toneClass,
|
|
182
188
|
className
|
|
183
189
|
),
|
|
184
190
|
onClick: handleClick,
|
|
185
191
|
disabled: isDisabled,
|
|
186
192
|
"aria-disabled": isDisabled,
|
|
187
193
|
...props,
|
|
188
|
-
children:
|
|
194
|
+
children: actionLabel
|
|
189
195
|
}
|
|
190
196
|
);
|
|
191
197
|
};
|
|
@@ -391,7 +397,8 @@ var import_menus = require("@edux-design/menus");
|
|
|
391
397
|
var import_avatars3 = require("@edux-design/avatars");
|
|
392
398
|
var import_jsx_runtime4 = require("react/jsx-runtime");
|
|
393
399
|
var CommentMention = ({
|
|
394
|
-
peopleList = []
|
|
400
|
+
peopleList = [],
|
|
401
|
+
onQueryChange
|
|
395
402
|
}) => {
|
|
396
403
|
const { editor, editorContainerRef } = useCommentComposer();
|
|
397
404
|
const [menuOpen, setMenuOpen] = (0, import_react6.useState)(false);
|
|
@@ -400,6 +407,7 @@ var CommentMention = ({
|
|
|
400
407
|
const [anchorStyle, setAnchorStyle] = (0, import_react6.useState)({ left: 0, top: 0 });
|
|
401
408
|
const menuListRef = (0, import_react6.useRef)(null);
|
|
402
409
|
const rangeRef = (0, import_react6.useRef)(null);
|
|
410
|
+
const lastQueryRef = (0, import_react6.useRef)("");
|
|
403
411
|
const peopleByHandle = (0, import_react6.useMemo)(
|
|
404
412
|
() => new Map(peopleList.map((person) => [person.handle.toLowerCase(), person])),
|
|
405
413
|
[peopleList]
|
|
@@ -426,6 +434,10 @@ var CommentMention = ({
|
|
|
426
434
|
const match = textBefore.match(/@([\w-]*)$/);
|
|
427
435
|
if (match) {
|
|
428
436
|
const handleQuery = match[1] ?? "";
|
|
437
|
+
if (onQueryChange && handleQuery !== lastQueryRef.current) {
|
|
438
|
+
lastQueryRef.current = handleQuery;
|
|
439
|
+
onQueryChange(handleQuery);
|
|
440
|
+
}
|
|
429
441
|
setQuery(handleQuery);
|
|
430
442
|
setMenuOpen(true);
|
|
431
443
|
const fromPos = pos - match[0].length;
|
|
@@ -446,7 +458,11 @@ var CommentMention = ({
|
|
|
446
458
|
setQuery("");
|
|
447
459
|
setRange(null);
|
|
448
460
|
rangeRef.current = null;
|
|
449
|
-
|
|
461
|
+
if (onQueryChange && lastQueryRef.current !== "") {
|
|
462
|
+
lastQueryRef.current = "";
|
|
463
|
+
onQueryChange("");
|
|
464
|
+
}
|
|
465
|
+
}, [editor, editorContainerRef, onQueryChange]);
|
|
450
466
|
const handleSelectPerson = (0, import_react6.useCallback)(
|
|
451
467
|
(person) => {
|
|
452
468
|
if (!editor || !rangeRef.current) return;
|
|
@@ -478,8 +494,12 @@ var CommentMention = ({
|
|
|
478
494
|
setQuery("");
|
|
479
495
|
setRange(null);
|
|
480
496
|
rangeRef.current = null;
|
|
497
|
+
if (onQueryChange && lastQueryRef.current !== "") {
|
|
498
|
+
lastQueryRef.current = "";
|
|
499
|
+
onQueryChange("");
|
|
500
|
+
}
|
|
481
501
|
},
|
|
482
|
-
[editor]
|
|
502
|
+
[editor, onQueryChange]
|
|
483
503
|
);
|
|
484
504
|
(0, import_react6.useEffect)(() => {
|
|
485
505
|
if (!editor) return;
|
package/dist/index.mjs
CHANGED
|
@@ -17,7 +17,8 @@ var VARIANT_STYLES = {
|
|
|
17
17
|
};
|
|
18
18
|
var CommentContext = createContext({
|
|
19
19
|
editable: true,
|
|
20
|
-
onResolve: void 0
|
|
20
|
+
onResolve: void 0,
|
|
21
|
+
resolved: false
|
|
21
22
|
});
|
|
22
23
|
var useCommentContext = () => useContext(CommentContext);
|
|
23
24
|
var Comment = forwardRef(function Comment2({
|
|
@@ -63,7 +64,7 @@ var Comment = forwardRef(function Comment2({
|
|
|
63
64
|
setShowAllReplies(!shouldCollapseReplies);
|
|
64
65
|
}, [shouldCollapseReplies]);
|
|
65
66
|
const visibleReplies = shouldCollapseReplies && !showAllReplies ? [replyItems[replyCount - 1]] : replyItems;
|
|
66
|
-
return /* @__PURE__ */ jsx(CommentContext.Provider, { value: { editable, onResolve }, children: /* @__PURE__ */ jsxs(
|
|
67
|
+
return /* @__PURE__ */ jsx(CommentContext.Provider, { value: { editable, onResolve, resolved: isResolved }, children: /* @__PURE__ */ jsxs(
|
|
67
68
|
"div",
|
|
68
69
|
{
|
|
69
70
|
ref,
|
|
@@ -139,24 +140,29 @@ var CommentResolve = ({
|
|
|
139
140
|
className,
|
|
140
141
|
disabled,
|
|
141
142
|
onClick,
|
|
143
|
+
label,
|
|
142
144
|
...props
|
|
143
145
|
}) => {
|
|
144
|
-
const { editable, onResolve } = useCommentContext();
|
|
146
|
+
const { editable, onResolve, resolved } = useCommentContext();
|
|
145
147
|
const isDisabled = disabled || !editable;
|
|
146
148
|
const handleClick = onClick ?? onResolve;
|
|
149
|
+
const isResolved = Boolean(resolved);
|
|
150
|
+
const actionLabel = label ?? (isResolved ? "Re-open" : "Resolve");
|
|
151
|
+
const toneClass = isResolved ? "text-fg-info-base" : "text-fg-success-base";
|
|
147
152
|
return /* @__PURE__ */ jsx(
|
|
148
153
|
"button",
|
|
149
154
|
{
|
|
150
155
|
type: "button",
|
|
151
156
|
className: cx(
|
|
152
|
-
"text-xs font-semibold
|
|
157
|
+
"text-xs font-semibold cursor-pointer",
|
|
158
|
+
toneClass,
|
|
153
159
|
className
|
|
154
160
|
),
|
|
155
161
|
onClick: handleClick,
|
|
156
162
|
disabled: isDisabled,
|
|
157
163
|
"aria-disabled": isDisabled,
|
|
158
164
|
...props,
|
|
159
|
-
children:
|
|
165
|
+
children: actionLabel
|
|
160
166
|
}
|
|
161
167
|
);
|
|
162
168
|
};
|
|
@@ -369,7 +375,8 @@ import { Menu, MenuList, MenuItem } from "@edux-design/menus";
|
|
|
369
375
|
import { Avatar as Avatar3 } from "@edux-design/avatars";
|
|
370
376
|
import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
|
|
371
377
|
var CommentMention = ({
|
|
372
|
-
peopleList = []
|
|
378
|
+
peopleList = [],
|
|
379
|
+
onQueryChange
|
|
373
380
|
}) => {
|
|
374
381
|
const { editor, editorContainerRef } = useCommentComposer();
|
|
375
382
|
const [menuOpen, setMenuOpen] = useState2(false);
|
|
@@ -378,6 +385,7 @@ var CommentMention = ({
|
|
|
378
385
|
const [anchorStyle, setAnchorStyle] = useState2({ left: 0, top: 0 });
|
|
379
386
|
const menuListRef = useRef2(null);
|
|
380
387
|
const rangeRef = useRef2(null);
|
|
388
|
+
const lastQueryRef = useRef2("");
|
|
381
389
|
const peopleByHandle = useMemo3(
|
|
382
390
|
() => new Map(peopleList.map((person) => [person.handle.toLowerCase(), person])),
|
|
383
391
|
[peopleList]
|
|
@@ -404,6 +412,10 @@ var CommentMention = ({
|
|
|
404
412
|
const match = textBefore.match(/@([\w-]*)$/);
|
|
405
413
|
if (match) {
|
|
406
414
|
const handleQuery = match[1] ?? "";
|
|
415
|
+
if (onQueryChange && handleQuery !== lastQueryRef.current) {
|
|
416
|
+
lastQueryRef.current = handleQuery;
|
|
417
|
+
onQueryChange(handleQuery);
|
|
418
|
+
}
|
|
407
419
|
setQuery(handleQuery);
|
|
408
420
|
setMenuOpen(true);
|
|
409
421
|
const fromPos = pos - match[0].length;
|
|
@@ -424,7 +436,11 @@ var CommentMention = ({
|
|
|
424
436
|
setQuery("");
|
|
425
437
|
setRange(null);
|
|
426
438
|
rangeRef.current = null;
|
|
427
|
-
|
|
439
|
+
if (onQueryChange && lastQueryRef.current !== "") {
|
|
440
|
+
lastQueryRef.current = "";
|
|
441
|
+
onQueryChange("");
|
|
442
|
+
}
|
|
443
|
+
}, [editor, editorContainerRef, onQueryChange]);
|
|
428
444
|
const handleSelectPerson = useCallback(
|
|
429
445
|
(person) => {
|
|
430
446
|
if (!editor || !rangeRef.current) return;
|
|
@@ -456,8 +472,12 @@ var CommentMention = ({
|
|
|
456
472
|
setQuery("");
|
|
457
473
|
setRange(null);
|
|
458
474
|
rangeRef.current = null;
|
|
475
|
+
if (onQueryChange && lastQueryRef.current !== "") {
|
|
476
|
+
lastQueryRef.current = "";
|
|
477
|
+
onQueryChange("");
|
|
478
|
+
}
|
|
459
479
|
},
|
|
460
|
-
[editor]
|
|
480
|
+
[editor, onQueryChange]
|
|
461
481
|
);
|
|
462
482
|
useEffect3(() => {
|
|
463
483
|
if (!editor) return;
|
package/package.json
CHANGED
|
@@ -56,9 +56,23 @@ export const Basic = (args) => (
|
|
|
56
56
|
}
|
|
57
57
|
>
|
|
58
58
|
<Comment.Actions>
|
|
59
|
+
<Comment.Resolve />
|
|
59
60
|
<Comment.Edit />
|
|
60
61
|
</Comment.Actions>
|
|
61
62
|
</Comment>
|
|
63
|
+
|
|
64
|
+
<Comment
|
|
65
|
+
author="Jordan Lee"
|
|
66
|
+
timestamp="Yesterday"
|
|
67
|
+
body="Resolved and ready to re-open if needed."
|
|
68
|
+
avatarProps={{ name: "Jordan Lee" }}
|
|
69
|
+
resolved
|
|
70
|
+
>
|
|
71
|
+
<Comment.Actions>
|
|
72
|
+
<Comment.Resolve />
|
|
73
|
+
<Comment.Resolve label="Re-open thread" />
|
|
74
|
+
</Comment.Actions>
|
|
75
|
+
</Comment>
|
|
62
76
|
</div>
|
|
63
77
|
);
|
|
64
78
|
|
|
@@ -86,6 +100,8 @@ export const Thread = () => {
|
|
|
86
100
|
},
|
|
87
101
|
]);
|
|
88
102
|
const [activeReplyId, setActiveReplyId] = React.useState(null);
|
|
103
|
+
const [recentlyResolvedId, setRecentlyResolvedId] = React.useState(null);
|
|
104
|
+
const [recentlyReopenedId, setRecentlyReopenedId] = React.useState(null);
|
|
89
105
|
|
|
90
106
|
const handleAddComment = (event) => {
|
|
91
107
|
event.preventDefault();
|
|
@@ -132,8 +148,19 @@ export const Thread = () => {
|
|
|
132
148
|
};
|
|
133
149
|
|
|
134
150
|
const handleResolve = (commentId) => {
|
|
135
|
-
setResolvedIds((prev) =>
|
|
151
|
+
setResolvedIds((prev) => {
|
|
152
|
+
const next = new Set(prev);
|
|
153
|
+
if (next.has(commentId)) {
|
|
154
|
+
next.delete(commentId);
|
|
155
|
+
setRecentlyReopenedId(commentId);
|
|
156
|
+
} else {
|
|
157
|
+
next.add(commentId);
|
|
158
|
+
setRecentlyResolvedId(commentId);
|
|
159
|
+
}
|
|
160
|
+
return next;
|
|
161
|
+
});
|
|
136
162
|
setActiveReplyId((prev) => (prev === commentId ? null : prev));
|
|
163
|
+
setLastAddedId(commentId);
|
|
137
164
|
};
|
|
138
165
|
|
|
139
166
|
React.useEffect(() => {
|
|
@@ -152,6 +179,22 @@ export const Thread = () => {
|
|
|
152
179
|
};
|
|
153
180
|
}, [lastAddedId]);
|
|
154
181
|
|
|
182
|
+
React.useEffect(() => {
|
|
183
|
+
if (!recentlyResolvedId) return;
|
|
184
|
+
const timeout = setTimeout(() => {
|
|
185
|
+
setRecentlyResolvedId(null);
|
|
186
|
+
}, 320);
|
|
187
|
+
return () => clearTimeout(timeout);
|
|
188
|
+
}, [recentlyResolvedId]);
|
|
189
|
+
|
|
190
|
+
React.useEffect(() => {
|
|
191
|
+
if (!recentlyReopenedId) return;
|
|
192
|
+
const timeout = setTimeout(() => {
|
|
193
|
+
setRecentlyReopenedId(null);
|
|
194
|
+
}, 320);
|
|
195
|
+
return () => clearTimeout(timeout);
|
|
196
|
+
}, [recentlyReopenedId]);
|
|
197
|
+
|
|
155
198
|
const getAnimationProps = (id) => {
|
|
156
199
|
if (id !== lastAddedId) return {};
|
|
157
200
|
return {
|
|
@@ -159,6 +202,8 @@ export const Thread = () => {
|
|
|
159
202
|
"transition-all duration-300 ease-out",
|
|
160
203
|
animationPhase === "enter" && "opacity-0 translate-y-2",
|
|
161
204
|
animationPhase === "entered" && "opacity-100 translate-y-0",
|
|
205
|
+
id === recentlyResolvedId && "ring-1 ring-border-success-base",
|
|
206
|
+
id === recentlyReopenedId && "ring-1 ring-border-info-base",
|
|
162
207
|
]
|
|
163
208
|
.filter(Boolean)
|
|
164
209
|
.join(" "),
|
|
@@ -220,7 +265,7 @@ export const Thread = () => {
|
|
|
220
265
|
}
|
|
221
266
|
/>
|
|
222
267
|
) : null}
|
|
223
|
-
|
|
268
|
+
<Comment.Resolve />
|
|
224
269
|
</Comment.Actions>
|
|
225
270
|
</Comment>
|
|
226
271
|
))}
|
|
@@ -272,6 +317,10 @@ export const ThreadInteractive = () => {
|
|
|
272
317
|
},
|
|
273
318
|
]);
|
|
274
319
|
const [activeReplyId, setActiveReplyId] = React.useState(null);
|
|
320
|
+
const [editingId, setEditingId] = React.useState(null);
|
|
321
|
+
const [editingValue, setEditingValue] = React.useState("");
|
|
322
|
+
const [recentlyResolvedId, setRecentlyResolvedId] = React.useState(null);
|
|
323
|
+
const [recentlyReopenedId, setRecentlyReopenedId] = React.useState(null);
|
|
275
324
|
|
|
276
325
|
const handleAddComment = (event) => {
|
|
277
326
|
event.preventDefault();
|
|
@@ -318,8 +367,42 @@ export const ThreadInteractive = () => {
|
|
|
318
367
|
};
|
|
319
368
|
|
|
320
369
|
const handleResolve = (commentId) => {
|
|
321
|
-
setResolvedIds((prev) =>
|
|
370
|
+
setResolvedIds((prev) => {
|
|
371
|
+
const next = new Set(prev);
|
|
372
|
+
if (next.has(commentId)) {
|
|
373
|
+
next.delete(commentId);
|
|
374
|
+
setRecentlyReopenedId(commentId);
|
|
375
|
+
} else {
|
|
376
|
+
next.add(commentId);
|
|
377
|
+
setRecentlyResolvedId(commentId);
|
|
378
|
+
}
|
|
379
|
+
return next;
|
|
380
|
+
});
|
|
322
381
|
setActiveReplyId((prev) => (prev === commentId ? null : prev));
|
|
382
|
+
setLastAddedId(commentId);
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
const handleStartEdit = (comment) => {
|
|
386
|
+
setEditingId(comment.id);
|
|
387
|
+
setEditingValue(comment.body);
|
|
388
|
+
setActiveReplyId((prev) => (prev === comment.id ? null : prev));
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
const handleSaveEdit = (commentId) => {
|
|
392
|
+
const trimmed = editingValue.trim();
|
|
393
|
+
if (!trimmed) return;
|
|
394
|
+
setComments((prev) =>
|
|
395
|
+
prev.map((comment) =>
|
|
396
|
+
comment.id === commentId ? { ...comment, body: trimmed } : comment
|
|
397
|
+
)
|
|
398
|
+
);
|
|
399
|
+
setEditingId(null);
|
|
400
|
+
setEditingValue("");
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
const handleCancelEdit = () => {
|
|
404
|
+
setEditingId(null);
|
|
405
|
+
setEditingValue("");
|
|
323
406
|
};
|
|
324
407
|
|
|
325
408
|
React.useEffect(() => {
|
|
@@ -338,6 +421,22 @@ export const ThreadInteractive = () => {
|
|
|
338
421
|
};
|
|
339
422
|
}, [lastAddedId]);
|
|
340
423
|
|
|
424
|
+
React.useEffect(() => {
|
|
425
|
+
if (!recentlyResolvedId) return;
|
|
426
|
+
const timeout = setTimeout(() => {
|
|
427
|
+
setRecentlyResolvedId(null);
|
|
428
|
+
}, 320);
|
|
429
|
+
return () => clearTimeout(timeout);
|
|
430
|
+
}, [recentlyResolvedId]);
|
|
431
|
+
|
|
432
|
+
React.useEffect(() => {
|
|
433
|
+
if (!recentlyReopenedId) return;
|
|
434
|
+
const timeout = setTimeout(() => {
|
|
435
|
+
setRecentlyReopenedId(null);
|
|
436
|
+
}, 320);
|
|
437
|
+
return () => clearTimeout(timeout);
|
|
438
|
+
}, [recentlyReopenedId]);
|
|
439
|
+
|
|
341
440
|
const getAnimationProps = (id) => {
|
|
342
441
|
if (id !== lastAddedId) return {};
|
|
343
442
|
return {
|
|
@@ -345,6 +444,8 @@ export const ThreadInteractive = () => {
|
|
|
345
444
|
"transition-all duration-300 ease-out",
|
|
346
445
|
animationPhase === "enter" && "opacity-0 translate-y-2",
|
|
347
446
|
animationPhase === "entered" && "opacity-100 translate-y-0",
|
|
447
|
+
id === recentlyResolvedId && "ring-1 ring-border-success-base",
|
|
448
|
+
id === recentlyReopenedId && "ring-1 ring-border-info-base",
|
|
348
449
|
]
|
|
349
450
|
.filter(Boolean)
|
|
350
451
|
.join(" "),
|
|
@@ -371,7 +472,24 @@ export const ThreadInteractive = () => {
|
|
|
371
472
|
key={comment.id}
|
|
372
473
|
author={comment.author}
|
|
373
474
|
timestamp={comment.timestamp}
|
|
374
|
-
body={
|
|
475
|
+
body={
|
|
476
|
+
editingId === comment.id ? (
|
|
477
|
+
<CommentComposer
|
|
478
|
+
value={editingValue}
|
|
479
|
+
onChange={(event) => setEditingValue(event.target.value)}
|
|
480
|
+
onSubmit={(event) => {
|
|
481
|
+
event.preventDefault();
|
|
482
|
+
handleSaveEdit(comment.id);
|
|
483
|
+
}}
|
|
484
|
+
onCancel={handleCancelEdit}
|
|
485
|
+
submitLabel="Save"
|
|
486
|
+
cancelLabel="Cancel"
|
|
487
|
+
rows={3}
|
|
488
|
+
/>
|
|
489
|
+
) : (
|
|
490
|
+
comment.body
|
|
491
|
+
)
|
|
492
|
+
}
|
|
375
493
|
avatarProps={{ name: comment.author }}
|
|
376
494
|
resolved={resolvedIds.has(comment.id)}
|
|
377
495
|
{...getAnimationProps(comment.id)}
|
|
@@ -410,7 +528,10 @@ export const ThreadInteractive = () => {
|
|
|
410
528
|
}
|
|
411
529
|
/>
|
|
412
530
|
) : null}
|
|
413
|
-
{!resolvedIds.has(comment.id) ?
|
|
531
|
+
{!resolvedIds.has(comment.id) ? (
|
|
532
|
+
<Comment.Edit onClick={() => handleStartEdit(comment)} />
|
|
533
|
+
) : null}
|
|
534
|
+
<Comment.Resolve />
|
|
414
535
|
</Comment.Actions>
|
|
415
536
|
</Comment>
|
|
416
537
|
))}
|
|
@@ -46,6 +46,7 @@ const PEOPLE = [
|
|
|
46
46
|
|
|
47
47
|
export const WithMentions = () => {
|
|
48
48
|
const [value, setValue] = useState("");
|
|
49
|
+
const [query, setQuery] = useState("");
|
|
49
50
|
|
|
50
51
|
return (
|
|
51
52
|
<div className="max-w-xl relative">
|
|
@@ -55,8 +56,11 @@ export const WithMentions = () => {
|
|
|
55
56
|
placeholder="Write a comment..."
|
|
56
57
|
avatarProps={{ name: "Alex Johnson" }}
|
|
57
58
|
>
|
|
58
|
-
<CommentMention peopleList={PEOPLE} />
|
|
59
|
+
<CommentMention peopleList={PEOPLE} onQueryChange={setQuery} />
|
|
59
60
|
</CommentComposer>
|
|
61
|
+
<div className="mt-8 text-xs text-fg-subtle">
|
|
62
|
+
Mention query: <span className="font-semibold">{query || "—"}</span>
|
|
63
|
+
</div>
|
|
60
64
|
</div>
|
|
61
65
|
);
|
|
62
66
|
};
|
package/src/elements/Comment.jsx
CHANGED
|
@@ -18,6 +18,7 @@ const VARIANT_STYLES = {
|
|
|
18
18
|
const CommentContext = createContext({
|
|
19
19
|
editable: true,
|
|
20
20
|
onResolve: undefined,
|
|
21
|
+
resolved: false,
|
|
21
22
|
});
|
|
22
23
|
|
|
23
24
|
const useCommentContext = () => useContext(CommentContext);
|
|
@@ -98,7 +99,7 @@ export const Comment = forwardRef(function Comment(
|
|
|
98
99
|
: replyItems;
|
|
99
100
|
|
|
100
101
|
return (
|
|
101
|
-
<CommentContext.Provider value={{ editable, onResolve }}>
|
|
102
|
+
<CommentContext.Provider value={{ editable, onResolve, resolved: isResolved }}>
|
|
102
103
|
<div
|
|
103
104
|
ref={ref}
|
|
104
105
|
className={cx(
|
|
@@ -200,17 +201,22 @@ export const CommentResolve = ({
|
|
|
200
201
|
className,
|
|
201
202
|
disabled,
|
|
202
203
|
onClick,
|
|
204
|
+
label,
|
|
203
205
|
...props
|
|
204
206
|
}) => {
|
|
205
|
-
const { editable, onResolve } = useCommentContext();
|
|
207
|
+
const { editable, onResolve, resolved } = useCommentContext();
|
|
206
208
|
const isDisabled = disabled || !editable;
|
|
207
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";
|
|
208
213
|
|
|
209
214
|
return (
|
|
210
215
|
<button
|
|
211
216
|
type="button"
|
|
212
217
|
className={cx(
|
|
213
|
-
"text-xs font-semibold
|
|
218
|
+
"text-xs font-semibold cursor-pointer",
|
|
219
|
+
toneClass,
|
|
214
220
|
className
|
|
215
221
|
)}
|
|
216
222
|
onClick={handleClick}
|
|
@@ -218,7 +224,7 @@ export const CommentResolve = ({
|
|
|
218
224
|
aria-disabled={isDisabled}
|
|
219
225
|
{...props}
|
|
220
226
|
>
|
|
221
|
-
|
|
227
|
+
{actionLabel}
|
|
222
228
|
</button>
|
|
223
229
|
);
|
|
224
230
|
};
|
|
@@ -114,6 +114,20 @@ describe("Comment", () => {
|
|
|
114
114
|
expect(screen.queryByText("Hidden body")).not.toBeInTheDocument();
|
|
115
115
|
});
|
|
116
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
|
+
|
|
117
131
|
it("fires onResolve when resolve button is clicked", () => {
|
|
118
132
|
const handleResolve = vi.fn();
|
|
119
133
|
render(
|
|
@@ -15,12 +15,14 @@ import { useCommentComposer } from "./CommentComposer";
|
|
|
15
15
|
/**
|
|
16
16
|
* @typedef {{
|
|
17
17
|
* peopleList: CommentMentionPerson[],
|
|
18
|
+
* onQueryChange?: (query: string) => void,
|
|
18
19
|
* placeholder?: string,
|
|
19
20
|
* }} CommentMentionProps
|
|
20
21
|
*/
|
|
21
22
|
|
|
22
23
|
export const CommentMention = ({
|
|
23
24
|
peopleList = [],
|
|
25
|
+
onQueryChange,
|
|
24
26
|
}) => {
|
|
25
27
|
const { editor, editorContainerRef } = useCommentComposer();
|
|
26
28
|
|
|
@@ -30,6 +32,7 @@ export const CommentMention = ({
|
|
|
30
32
|
const [anchorStyle, setAnchorStyle] = useState({ left: 0, top: 0 });
|
|
31
33
|
const menuListRef = useRef(null);
|
|
32
34
|
const rangeRef = useRef(null);
|
|
35
|
+
const lastQueryRef = useRef("");
|
|
33
36
|
|
|
34
37
|
const peopleByHandle = useMemo(
|
|
35
38
|
() => new Map(peopleList.map((person) => [person.handle.toLowerCase(), person])),
|
|
@@ -61,6 +64,10 @@ export const CommentMention = ({
|
|
|
61
64
|
|
|
62
65
|
if (match) {
|
|
63
66
|
const handleQuery = match[1] ?? "";
|
|
67
|
+
if (onQueryChange && handleQuery !== lastQueryRef.current) {
|
|
68
|
+
lastQueryRef.current = handleQuery;
|
|
69
|
+
onQueryChange(handleQuery);
|
|
70
|
+
}
|
|
64
71
|
setQuery(handleQuery);
|
|
65
72
|
setMenuOpen(true);
|
|
66
73
|
const fromPos = pos - match[0].length;
|
|
@@ -82,7 +89,11 @@ export const CommentMention = ({
|
|
|
82
89
|
setQuery("");
|
|
83
90
|
setRange(null);
|
|
84
91
|
rangeRef.current = null;
|
|
85
|
-
|
|
92
|
+
if (onQueryChange && lastQueryRef.current !== "") {
|
|
93
|
+
lastQueryRef.current = "";
|
|
94
|
+
onQueryChange("");
|
|
95
|
+
}
|
|
96
|
+
}, [editor, editorContainerRef, onQueryChange]);
|
|
86
97
|
|
|
87
98
|
const handleSelectPerson = useCallback(
|
|
88
99
|
(person) => {
|
|
@@ -116,8 +127,12 @@ export const CommentMention = ({
|
|
|
116
127
|
setQuery("");
|
|
117
128
|
setRange(null);
|
|
118
129
|
rangeRef.current = null;
|
|
130
|
+
if (onQueryChange && lastQueryRef.current !== "") {
|
|
131
|
+
lastQueryRef.current = "";
|
|
132
|
+
onQueryChange("");
|
|
133
|
+
}
|
|
119
134
|
},
|
|
120
|
-
[editor]
|
|
135
|
+
[editor, onQueryChange]
|
|
121
136
|
);
|
|
122
137
|
|
|
123
138
|
useEffect(() => {
|
|
@@ -135,4 +135,16 @@ describe("CommentMention", () => {
|
|
|
135
135
|
|
|
136
136
|
expect(focusSpy).toHaveBeenCalled();
|
|
137
137
|
});
|
|
138
|
+
|
|
139
|
+
it("emits mention queries when typing", () => {
|
|
140
|
+
const handleQueryChange = vi.fn();
|
|
141
|
+
render(
|
|
142
|
+
<CommentMention
|
|
143
|
+
peopleList={[{ id: "1", name: "Alex Johnson", handle: "alex" }]}
|
|
144
|
+
onQueryChange={handleQueryChange}
|
|
145
|
+
/>
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
expect(handleQueryChange).toHaveBeenCalledWith("al");
|
|
149
|
+
});
|
|
138
150
|
});
|