Files

239 lines
7.9 KiB
TypeScript

"use client";
/**
* MentionTextarea — a textarea with inline "@" mention autocomplete.
*
* As the user types an `@token` at the caret, a suggestion list opens above
* the field (prioritised following → followers → others). Picking a user (tap
* or ↑/↓ + Enter/Tab) replaces the token with `@handle `. The list is portaled
* with `pointer-events-auto` so it works inside a modal vaul drawer, and
* dismisses on blur — no document listener that could fight the drawer.
*
* Enter (without Shift) submits via `onSubmit` ONLY when no suggestion list is
* open; while the list is open, Enter/Tab pick the highlighted user. The
* underlying <textarea> ref is forwarded so callers can focus it / splice text
* (e.g. the emoji picker).
*/
import {
forwardRef,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import { Loader2 } from "lucide-react";
import { posts, type UserSummary } from "@/lib/social";
import { UserAvatar } from "@/components/ui/user-avatar";
const SUGGEST_LIMIT = 8;
// "@" at line start or after whitespace, then up to 30 handle chars, at the
// end of the text before the caret. Avoids matching emails (foo@bar).
const MENTION_RE = /(^|\s)@([A-Za-z0-9_]{0,30})$/;
type Mention = { start: number; query: string };
type PanelPos = { left: number; bottom: number; width: number };
export const MentionTextarea = forwardRef<
HTMLTextAreaElement,
{
value: string;
onChange: (v: string) => void;
/** Called on Enter (no Shift) when the suggestion list is closed. */
onSubmit?: () => void;
placeholder?: string;
className?: string;
maxLength?: number;
rows?: number;
}
>(function MentionTextarea(
{ value, onChange, onSubmit, placeholder, className, maxLength, rows = 1 },
ref,
) {
const innerRef = useRef<HTMLTextAreaElement>(null);
useImperativeHandle(ref, () => innerRef.current as HTMLTextAreaElement, []);
const [mention, setMention] = useState<Mention | null>(null);
const [items, setItems] = useState<UserSummary[]>([]);
const [loading, setLoading] = useState(false);
const [active, setActive] = useState(0);
const [pos, setPos] = useState<PanelPos | null>(null);
const reqRef = useRef(0);
const blurTimer = useRef<number | null>(null);
// Detect the active @token from the text up to the caret.
function detect(el: HTMLTextAreaElement) {
const caret = el.selectionStart ?? 0;
const m = MENTION_RE.exec(el.value.slice(0, caret));
if (!m) {
setMention(null);
return;
}
setMention({ start: caret - m[2].length - 1, query: m[2] });
setActive(0);
const r = el.getBoundingClientRect();
setPos({
left: r.left,
bottom: window.innerHeight - r.top + 6,
width: r.width,
});
}
// Fetch suggestions (debounced) whenever the active token changes.
useEffect(() => {
if (!mention) return;
const token = ++reqRef.current;
const t = window.setTimeout(() => {
setLoading(true);
posts
.mentionSuggest(mention.query, { limit: SUGGEST_LIMIT, offset: 0 })
.then((res) => {
if (reqRef.current === token) setItems(res);
})
.catch(() => {
if (reqRef.current === token) setItems([]);
})
.finally(() => {
if (reqRef.current === token) setLoading(false);
});
}, 150);
return () => window.clearTimeout(t);
}, [mention]);
function select(u: UserSummary) {
const el = innerRef.current;
if (!el || !mention) return;
const caret = el.selectionStart ?? value.length;
const insert = `@${u.handle} `;
const next = value.slice(0, mention.start) + insert + value.slice(caret);
onChange(next);
setMention(null);
const newCaret = mention.start + insert.length;
requestAnimationFrame(() => {
const node = innerRef.current;
if (!node) return;
node.focus();
node.setSelectionRange(newCaret, newCaret);
});
}
const listOpen = !!mention && (loading || items.length > 0);
function onKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
if (listOpen && items.length > 0) {
if (e.key === "ArrowDown") {
e.preventDefault();
setActive((a) => Math.min(a + 1, items.length - 1));
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
setActive((a) => Math.max(a - 1, 0));
return;
}
if (e.key === "Enter" || e.key === "Tab") {
e.preventDefault();
select(items[active] ?? items[0]);
return;
}
if (e.key === "Escape") {
e.preventDefault();
setMention(null);
return;
}
}
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
onSubmit?.();
}
}
return (
<div className="relative flex-1">
<textarea
ref={innerRef}
value={value}
rows={rows}
maxLength={maxLength}
placeholder={placeholder}
className={className}
autoComplete="off"
onChange={(e) => {
onChange(e.target.value);
detect(e.target);
}}
onKeyUp={(e) => detect(e.currentTarget)}
onClick={(e) => detect(e.currentTarget)}
onKeyDown={onKeyDown}
onBlur={() => {
// Defer so a result tap (mousedown→click) lands before close.
blurTimer.current = window.setTimeout(() => setMention(null), 120);
}}
/>
{listOpen && pos && typeof document !== "undefined"
? createPortal(
<div
role="listbox"
aria-label="Mention suggestions"
style={{
position: "fixed",
left: pos.left,
bottom: pos.bottom,
width: pos.width,
}}
// pointer-events-auto: the comments drawer is a modal vaul sheet
// (body gets pointer-events:none); without this the list would be
// click-through.
className="pointer-events-auto z-[101] max-h-56 overflow-y-auto overscroll-contain rounded-lg border border-border bg-card shadow-xl"
onTouchMove={(e) => e.stopPropagation()}
>
{items.map((u, i) => (
<button
key={u.id ?? u.handle}
type="button"
role="option"
aria-selected={i === active}
// Keep textarea focus + cancel the pending blur-close so the
// click registers before the list would otherwise close.
onMouseDown={(e) => {
e.preventDefault();
if (blurTimer.current)
window.clearTimeout(blurTimer.current);
}}
onMouseEnter={() => setActive(i)}
onClick={() => select(u)}
className={`flex w-full items-center gap-2.5 px-3 py-2 text-left transition-colors ${
i === active ? "bg-muted/70" : "hover:bg-muted/60"
}`}
>
<UserAvatar
src={u.avatar_url}
name={u.name}
className="size-8"
textClassName="text-xs"
/>
<span className="min-w-0">
<span className="block truncate text-sm font-medium text-foreground">
{u.name}
</span>
<span className="block truncate text-xs text-muted-foreground">
@{u.handle}
</span>
</span>
</button>
))}
{loading ? (
<div className="flex justify-center py-2">
<Loader2 className="size-4 animate-spin text-muted-foreground" />
</div>
) : null}
</div>,
document.body,
)
: null}
</div>
);
});