shoc-frontend-new/src/components/common/comment-mention-composer.tsx
Arthur Bassi f35fce9bd5
feat(work-orders): comment @mentions autocomplete (SH-217) (#91)
* docs(work-orders): add pending BE contract checklist for design parity gaps

* feat(work-orders): add mentions in work-order comments composer

* docs(work-orders): align pr description with jira and qa residuals

* refactor(workorders): split work order view page into view components

* fix(work-orders): export comment assignment vendor input types for view hook

* fix(work-orders): drop preferredContact from vendor update payload

Align with WorkOrderVendorUpdatePayload on current dev so the mentions branch typechecks.

* fix(work-orders): remove Preferred Contact from WO vendor editor (SH-180)

EOF

* fix(work-orders): harden comment mentions and clear draft on success

* fix(work-orders): harden mention highlight boundary and submit catch [SH-217]

* fix(work-orders): keep mention cursor sync on current value [SH-217]

* fix(work-orders): keep mention menu open after typing @

Pass live text into syncCursor instead of a render-time valueRef.
Add a composer interaction regression for @ autocomplete visibility.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Alexandre Brandizzi <alex_brandizzi@hotmail.com>
2026-08-11 14:07:02 -03:00

205 lines
5.3 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Box,
ClickAwayListener,
List,
ListItemButton,
Paper,
Popper,
TextField,
} from "@mui/material";
import {
filterMentionCandidates,
findActiveMentionQuery,
insertMentionToken,
type MentionLookup,
} from "@/domain/work-orders/utils/comment-mentions";
type CommentMentionComposerProps = {
value: string;
onChange: (value: string) => void;
candidates: MentionLookup[];
placeholder?: string;
disabled?: boolean;
rows?: number;
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 (
<Popper open={open} anchorEl={anchorEl} placement="bottom-start" className="z-50">
<Paper elevation={4} className="mt-1 max-h-48 overflow-y-auto">
<List dense disablePadding>
{candidates.map((candidate, index) => (
<ListItemButton
key={String(candidate.id)}
selected={index === highlightIndex}
onMouseDown={(event) => event.preventDefault()}
onClick={() => onSelect(candidate)}
>
@{candidate.name}
</ListItemButton>
))}
</List>
</Paper>
</Popper>
);
}
export function CommentMentionComposer({
value,
onChange,
candidates,
placeholder = "Type a comment...",
disabled = false,
rows = 2,
onSubmit,
}: CommentMentionComposerProps) {
const inputRef = useRef<HTMLTextAreaElement | HTMLInputElement>(null);
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const [cursor, setCursor] = useState(0);
const [menuOpen, setMenuOpen] = useState(false);
const [highlightIndex, setHighlightIndex] = useState(0);
const activeQuery = useMemo(() => findActiveMentionQuery(value, cursor), [value, cursor]);
const filteredCandidates = useMemo(
() => filterMentionCandidates(candidates, activeQuery?.query ?? ""),
[activeQuery?.query, candidates],
);
const showMenu = Boolean(menuOpen && activeQuery && filteredCandidates.length > 0);
useEffect(() => {
setHighlightIndex(0);
}, [activeQuery?.query, activeQuery?.start]);
const syncCursor = useCallback((textOverride?: string) => {
const element = inputRef.current;
if (!element) {
return;
}
const text = textOverride ?? element.value;
const nextCursor = element.selectionStart ?? text.length;
setCursor(nextCursor);
setMenuOpen(Boolean(findActiveMentionQuery(text, nextCursor)));
}, []);
const handleChange = (nextValue: string) => {
onChange(nextValue);
requestAnimationFrame(() => {
syncCursor(nextValue);
});
};
const applyMention = useCallback(
(user: MentionLookup) => {
if (!activeQuery) {
return;
}
const { nextText, nextCursor } = insertMentionToken(value, activeQuery.start, cursor, user);
onChange(nextText);
setMenuOpen(false);
setHighlightIndex(0);
requestAnimationFrame(() => {
const element = inputRef.current;
if (!element) {
return;
}
element.focus();
element.setSelectionRange(nextCursor, nextCursor);
setCursor(nextCursor);
});
},
[activeQuery, cursor, onChange, value],
);
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (showMenu) {
if (event.key === "ArrowDown") {
event.preventDefault();
setHighlightIndex((current) => (current + 1) % filteredCandidates.length);
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
setHighlightIndex(
(current) => (current - 1 + filteredCandidates.length) % filteredCandidates.length,
);
return;
}
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
const selected = filteredCandidates[highlightIndex];
if (selected) {
applyMention(selected);
}
return;
}
if (event.key === "Escape") {
event.preventDefault();
setMenuOpen(false);
return;
}
}
if (event.key === "Enter" && (event.metaKey || event.ctrlKey) && onSubmit) {
event.preventDefault();
onSubmit();
}
};
return (
<ClickAwayListener onClickAway={() => setMenuOpen(false)}>
<Box
className="relative"
ref={(node: HTMLDivElement | null) => {
setAnchorEl(node);
}}
>
<TextField
inputRef={inputRef}
multiline
rows={rows}
fullWidth
value={value}
onChange={(event) => handleChange(event.target.value)}
onClick={() => syncCursor()}
onKeyUp={() => syncCursor()}
onSelect={() => syncCursor()}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={disabled}
/>
<MentionCandidateMenu
open={showMenu}
anchorEl={anchorEl}
candidates={filteredCandidates}
highlightIndex={highlightIndex}
onSelect={applyMention}
/>
</Box>
</ClickAwayListener>
);
}