mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 16:19:01 +00:00
176 lines
4.8 KiB
TypeScript
176 lines
4.8 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;
|
|
};
|
|
|
|
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(() => {
|
|
const element = inputRef.current;
|
|
if (!element) {
|
|
return;
|
|
}
|
|
|
|
const nextCursor = element.selectionStart ?? value.length;
|
|
setCursor(nextCursor);
|
|
setMenuOpen(Boolean(findActiveMentionQuery(value, nextCursor)));
|
|
}, [value]);
|
|
|
|
const handleChange = (nextValue: string) => {
|
|
onChange(nextValue);
|
|
requestAnimationFrame(syncCursor);
|
|
};
|
|
|
|
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}
|
|
/>
|
|
|
|
<Popper open={showMenu} anchorEl={anchorEl} placement="bottom-start" className="z-50">
|
|
<Paper elevation={4} className="mt-1 max-h-48 overflow-y-auto">
|
|
<List dense disablePadding>
|
|
{filteredCandidates.map((candidate, index) => (
|
|
<ListItemButton
|
|
key={String(candidate.id)}
|
|
selected={index === highlightIndex}
|
|
onMouseDown={(event) => event.preventDefault()}
|
|
onClick={() => applyMention(candidate)}
|
|
>
|
|
@{candidate.name}
|
|
</ListItemButton>
|
|
))}
|
|
</List>
|
|
</Paper>
|
|
</Popper>
|
|
</Box>
|
|
</ClickAwayListener>
|
|
);
|
|
}
|