teecup/frontend/components/mention-input.tsx

239 lines
7.8 KiB
TypeScript
Raw Normal View History

"use client"
// @-tagging (ADR-063) -- delt UI mellom innleggs-komposereren
// (round-messages.tsx) og kommentar-komposereren (post-engagement.tsx).
// Selve logikken (offset-regning, tekst-diffing) er bevisst flyttet ut i
// lib/mentions.ts og enhetstestet der -- denne fila er ren presentasjon
// og DOM-/fokushåndtering.
import type React from "react"
import Link from "next/link"
import { useEffect, useRef, useState } from "react"
import { cn } from "@/lib/utils"
import {
adjustTagsForEdit,
detectActiveMention,
fetchTaggablePeople,
insertMention,
segmentTaggedText,
type Tag,
type TaggablePerson,
} from "@/lib/mentions"
type MentionTextareaProps = {
roundId: string
value: string
tags: Tag[]
onChange: (value: string, tags: Tag[]) => void
placeholder?: string
rows?: number
id?: string
className?: string
autoFocus?: boolean
// Videresendes KUN når dropdownen ikke er åpen -- Enter/piltaster skal
// styre forslagslisten når den vises, ikke utløse f.eks. avsenderens
// egen Cmd/Ctrl+Enter-innsending samtidig (post-engagement.tsx).
onKeyDownWhenClosed?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void
}
export function MentionTextarea({
roundId,
value,
tags,
onChange,
placeholder,
rows = 3,
id,
className,
autoFocus,
onKeyDownWhenClosed,
}: MentionTextareaProps) {
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
const [mention, setMention] = useState<{ start: number; query: string } | null>(null)
const [suggestions, setSuggestions] = useState<TaggablePerson[]>([])
const [loadingSuggestions, setLoadingSuggestions] = useState(false)
const [highlighted, setHighlighted] = useState(0)
// Markørposisjonen som stod da @-forsøket sist ble oppdatert -- trengs
// for å vite hvor SLUTTEN av "@søketekst" er ved faktisk innsetting
// (markøren kan ha rukket å flytte seg videre siden selve deteksjonen).
const cursorAtDetectionRef = useRef(0)
const pendingCursorRef = useRef<number | null>(null)
const open = mention !== null
// Debouncet henting av kandidater -- kun mens dropdownen faktisk er åpen.
useEffect(() => {
if (!open) {
setSuggestions([])
return
}
let cancelled = false
setLoadingSuggestions(true)
const handle = setTimeout(async () => {
const people = await fetchTaggablePeople(roundId, mention.query)
if (!cancelled) {
setSuggestions(people)
setHighlighted(0)
setLoadingSuggestions(false)
}
}, 200)
return () => {
cancelled = true
clearTimeout(handle)
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- kun query/roundId skal trigge ny henting
}, [open, mention?.query, roundId])
// Sett markøren riktig sted ETTER at React har rendret den nye,
// lengre/kortere verdien -- kan ikke gjøres synkront i onChange, siden
// <textarea>s DOM-verdi ikke er oppdatert med den nye teksten ennå der.
useEffect(() => {
if (pendingCursorRef.current !== null && textareaRef.current) {
textareaRef.current.setSelectionRange(pendingCursorRef.current, pendingCursorRef.current)
pendingCursorRef.current = null
}
}, [value])
function handleChange(e: React.ChangeEvent<HTMLTextAreaElement>) {
const newValue = e.target.value
const cursor = e.target.selectionStart
const adjustedTags = adjustTagsForEdit(value, newValue, tags)
onChange(newValue, adjustedTags)
const active = detectActiveMention(newValue, cursor)
cursorAtDetectionRef.current = cursor
setMention(active)
}
function selectSuggestion(person: TaggablePerson) {
if (!mention || !textareaRef.current) return
const { body: newBody, tag, cursor } = insertMention(value, mention.start, cursorAtDetectionRef.current, person)
onChange(newBody, [...tags, tag])
setMention(null)
pendingCursorRef.current = cursor
textareaRef.current.focus()
}
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
if (open && suggestions.length > 0) {
if (e.key === "ArrowDown") {
e.preventDefault()
setHighlighted((i) => (i + 1) % suggestions.length)
return
}
if (e.key === "ArrowUp") {
e.preventDefault()
setHighlighted((i) => (i - 1 + suggestions.length) % suggestions.length)
return
}
if (e.key === "Enter" || e.key === "Tab") {
e.preventDefault()
selectSuggestion(suggestions[highlighted])
return
}
if (e.key === "Escape") {
e.preventDefault()
setMention(null)
return
}
} else if (open && e.key === "Escape") {
e.preventDefault()
setMention(null)
return
}
onKeyDownWhenClosed?.(e)
}
return (
<div className="relative">
<textarea
ref={textareaRef}
id={id}
autoFocus={autoFocus}
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
onBlur={() => {
// Liten forsinkelse: en mouseDown på et forslag skal rekke å
// trigge FØR blur lukker listen (ellers mistes klikket).
window.setTimeout(() => setMention(null), 150)
}}
placeholder={placeholder}
rows={rows}
className={className}
/>
{open && (
<div
role="listbox"
aria-label="Forslag til tagging"
className="absolute left-0 right-0 top-full z-20 mt-1 max-h-60 overflow-y-auto rounded-xl border border-border bg-card shadow-lg"
>
{loadingSuggestions ? (
<div className="flex items-center justify-center p-3">
<span
aria-hidden="true"
className="size-5 animate-spin rounded-full border-2 border-primary/20 border-t-primary"
/>
<span className="sr-only">Søker...</span>
</div>
) : suggestions.length === 0 ? (
<p className="p-3 text-sm text-muted-foreground">Ingen treff</p>
) : (
<ul>
{suggestions.map((person, i) => (
<li key={person.user_id}>
<button
type="button"
role="option"
aria-selected={i === highlighted}
onMouseDown={(e) => {
// preventDefault: unngår at textarea mister fokus
// FØR onClick rekker å kjøre.
e.preventDefault()
selectSuggestion(person)
}}
onMouseEnter={() => setHighlighted(i)}
className={cn(
"flex min-h-11 w-full items-center px-3 text-left text-base text-foreground",
i === highlighted ? "bg-accent" : "hover:bg-accent/50",
)}
>
{person.display_name}
</button>
</li>
))}
</ul>
)}
</div>
)}
</div>
)
}
/** Rendrer body-tekst med taggede utsnitt som lenker til vedkommendes
* profil (`/my-friends/{id}`) -- tydelig annerledes enn vanlig tekst,
* bevisst UTEN understrek (ligger for tett inntil resten av teksten
* visuelt sett til at en vanlig lenkestil er lesbar der). */
export function TaggedText({ body, tags, className }: { body: string; tags: Tag[]; className?: string }) {
if (tags.length === 0) return <span className={className}>{body}</span>
const segments = segmentTaggedText(body, tags)
return (
<span className={className}>
{segments.map((seg, i) =>
seg.tag ? (
<Link
key={i}
href={`/my-friends/${seg.tag.user_id}`}
className="font-semibold text-primary no-underline hover:underline"
>
{seg.text}
</Link>
) : (
<span key={i}>{seg.text}</span>
),
)}
</span>
)
}