@edux-design/comments 0.1.2 → 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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # @edux-design/comments
2
2
 
3
+ ## 0.1.4
4
+
5
+ ### Patch Changes
6
+
7
+ - Added hooks and callbacks for consumer support
8
+
9
+ ## 0.1.3
10
+
11
+ ### Patch Changes
12
+
13
+ - Recompiled build
14
+
3
15
  ## 0.1.2
4
16
 
5
17
  ### Patch Changes
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
- onResolve={() => {
25
- // mark resolved in your app state
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 text-fg-success-base cursor-pointer",
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: "Resolve"
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
- }, [editor]);
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 text-fg-success-base cursor-pointer",
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: "Resolve"
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
- }, [editor]);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edux-design/comments",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "private": false,
5
5
  "sideEffects": [
6
6
  "**/*.css"
@@ -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) => new Set(prev).add(commentId));
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
- {!resolvedIds.has(comment.id) ? <Comment.Resolve /> : null}
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) => new Set(prev).add(commentId));
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={comment.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) ? <Comment.Resolve /> : null}
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
  };
@@ -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 text-fg-success-base cursor-pointer",
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
- Resolve
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
- }, [editor]);
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
  });