diff --git a/src/components/common/comment-mention-composer.tsx b/src/components/common/comment-mention-composer.tsx
index 63c84f22..140753b0 100644
--- a/src/components/common/comment-mention-composer.tsx
+++ b/src/components/common/comment-mention-composer.tsx
@@ -25,6 +25,41 @@ type CommentMentionComposerProps = {
onSubmit?: () => void;
};
+type MentionCandidateMenuProps = {
+ open: boolean;
+ anchorEl: HTMLElement | null;
+ candidates: MentionLookup[];
+ highlightIndex: number;
+ onSelect: (user: MentionLookup) => void;
+};
+
+function MentionCandidateMenu({
+ open,
+ anchorEl,
+ candidates,
+ highlightIndex,
+ onSelect,
+}: MentionCandidateMenuProps) {
+ return (
+
+
+
+ {candidates.map((candidate, index) => (
+ event.preventDefault()}
+ onClick={() => onSelect(candidate)}
+ >
+ @{candidate.name}
+
+ ))}
+
+
+
+ );
+}
+
export function CommentMentionComposer({
value,
onChange,
@@ -35,8 +70,6 @@ export function CommentMentionComposer({
onSubmit,
}: CommentMentionComposerProps) {
const inputRef = useRef(null);
- const valueRef = useRef(value);
- valueRef.current = value;
const [anchorEl, setAnchorEl] = useState(null);
const [cursor, setCursor] = useState(0);
const [menuOpen, setMenuOpen] = useState(false);
@@ -55,22 +88,23 @@ export function CommentMentionComposer({
setHighlightIndex(0);
}, [activeQuery?.query, activeQuery?.start]);
- const syncCursor = useCallback(() => {
+ const syncCursor = useCallback((textOverride?: string) => {
const element = inputRef.current;
if (!element) {
return;
}
- const currentValue = valueRef.current;
- const nextCursor = element.selectionStart ?? currentValue.length;
+ const text = textOverride ?? element.value;
+ const nextCursor = element.selectionStart ?? text.length;
setCursor(nextCursor);
- setMenuOpen(Boolean(findActiveMentionQuery(currentValue, nextCursor)));
+ setMenuOpen(Boolean(findActiveMentionQuery(text, nextCursor)));
}, []);
const handleChange = (nextValue: string) => {
- valueRef.current = nextValue;
onChange(nextValue);
- requestAnimationFrame(syncCursor);
+ requestAnimationFrame(() => {
+ syncCursor(nextValue);
+ });
};
const applyMention = useCallback(
@@ -150,30 +184,21 @@ export function CommentMentionComposer({
fullWidth
value={value}
onChange={(event) => handleChange(event.target.value)}
- onClick={syncCursor}
- onKeyUp={syncCursor}
- onSelect={syncCursor}
+ onClick={() => syncCursor()}
+ onKeyUp={() => syncCursor()}
+ onSelect={() => syncCursor()}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={disabled}
/>
-
-
-
- {filteredCandidates.map((candidate, index) => (
- event.preventDefault()}
- onClick={() => applyMention(candidate)}
- >
- @{candidate.name}
-
- ))}
-
-
-
+
);
diff --git a/src/test/components/common/comment-mention-composer.test.tsx b/src/test/components/common/comment-mention-composer.test.tsx
new file mode 100644
index 00000000..a732af50
--- /dev/null
+++ b/src/test/components/common/comment-mention-composer.test.tsx
@@ -0,0 +1,28 @@
+import { screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { useState } from "react";
+import { describe, expect, it } from "vitest";
+
+import { CommentMentionComposer } from "@/components/common/comment-mention-composer";
+import { renderWithProviders } from "@/test/test-utils";
+
+const candidates = [{ id: "1", name: "Alice Dispatcher" }];
+
+function MentionComposerHarness() {
+ const [value, setValue] = useState("");
+
+ return ;
+}
+
+describe("CommentMentionComposer", () => {
+ it("keeps the suggestion menu visible after typing @", async () => {
+ const user = userEvent.setup();
+ renderWithProviders(, { withAuth: false });
+
+ await user.type(screen.getByRole("textbox"), "@");
+
+ await waitFor(() => {
+ expect(screen.getByRole("button", { name: "@Alice Dispatcher" })).toBeVisible();
+ });
+ });
+});