import React, { KeyboardEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import classNames from 'classnames'; import { Icon, IconButton, Icons } from 'folds'; import { TOPIC_SEARCH_QUALIFIERS, collectAuthorHintsFromThreads, collectTitleHintsFromThreads, insertTopicSearchQualifierAt, insertTopicSearchValueAt, matchAuthorHints, matchTitleHints, matchTopicSearchQualifiers, parseTopicSearchQuery, readActiveQualifierValueAt, readQualifierPartialAt, type TopicSearchAuthorHint, type TopicSearchQualifier, } from './topicSearch'; import type { ForumThreadSummary } from './types'; import * as t from './forumTheme.css'; type ForumTopicSearchInputProps = { value: string; onChange: (value: string) => void; threads?: ForumThreadSummary[]; }; type SuggestionMode = 'qualifier' | 'author' | 'title'; export function ForumTopicSearchInput({ value, onChange, threads = [] }: ForumTopicSearchInputProps) { const inputRef = useRef(null); const mirrorRef = useRef(null); const pillsRef = useRef(null); const [focused, setFocused] = useState(false); const [caret, setCaret] = useState(0); const [highlightIndex, setHighlightIndex] = useState(0); const [tabPartial, setTabPartial] = useState(''); const authorHints = useMemo(() => collectAuthorHintsFromThreads(threads), [threads]); const titleHints = useMemo(() => collectTitleHintsFromThreads(threads), [threads]); const qualifierPartial = readQualifierPartialAt(value, caret); const valueContext = readActiveQualifierValueAt(value, caret); const qualifierMatches = qualifierPartial ? matchTopicSearchQualifiers(qualifierPartial) : TOPIC_SEARCH_QUALIFIERS; const authorMatches = useMemo( () => valueContext?.key === 'from' ? matchAuthorHints(valueContext.partial, authorHints) : [], [authorHints, valueContext] ); const titleMatches = useMemo( () => valueContext?.key === 'intitle' ? matchTitleHints(valueContext.partial, titleHints) : [], [titleHints, valueContext] ); const suggestionMode: SuggestionMode | null = useMemo(() => { if (!focused) { return null; } if (valueContext?.key === 'from' && authorHints.length > 0) { return 'author'; } if (valueContext?.key === 'intitle' && titleHints.length > 0) { return 'title'; } if (!qualifierPartial || qualifierMatches.length > 0) { return 'qualifier'; } return null; }, [ focused, valueContext, authorHints.length, titleHints.length, qualifierPartial, qualifierMatches.length, ]); const suggestionCount = suggestionMode === 'author' ? authorMatches.length : suggestionMode === 'title' ? titleMatches.length : suggestionMode === 'qualifier' ? qualifierMatches.length : 0; const showPills = suggestionMode !== null && suggestionCount > 0; const parsed = parseTopicSearchQuery(value); const positionFloatingPills = useCallback(() => { const input = inputRef.current; const root = pillsRef.current; const mirror = mirrorRef.current; if (!input || !root || root.hidden) { return; } const caretPos = input.selectionStart ?? input.value.length; const textBefore = input.value.slice(0, caretPos).replace(/\s/g, '\u00a0') || '\u200b'; if (mirror) { const inputStyle = window.getComputedStyle(input); mirror.textContent = textBefore; mirror.style.font = inputStyle.font; mirror.style.letterSpacing = inputStyle.letterSpacing; } const mirrorWidth = mirror?.offsetWidth ?? 0; const maxLeft = Math.max(0, input.clientWidth - root.offsetWidth); const left = Math.min(Math.max(0, mirrorWidth - 12), maxLeft); root.style.left = `${left}px`; const caretLeft = Math.min(Math.max(12, mirrorWidth), input.clientWidth - 12); root.style.setProperty('--forum-search-caret', `${caretLeft - left}px`); }, []); useEffect(() => { positionFloatingPills(); }, [value, caret, showPills, positionFloatingPills]); useEffect(() => { setHighlightIndex(0); setTabPartial(''); }, [suggestionMode, valueContext?.partial, qualifierPartial]); const applyInputUpdate = useCallback( (nextValue: string, nextCursor: number) => { onChange(nextValue); setTabPartial(''); setHighlightIndex(0); window.requestAnimationFrame(() => { const input = inputRef.current; if (!input) return; input.focus(); input.setSelectionRange(nextCursor, nextCursor); setCaret(nextCursor); positionFloatingPills(); }); }, [onChange, positionFloatingPills] ); const insertQualifier = useCallback( (qualifier: TopicSearchQualifier) => { const input = inputRef.current; if (!input) return; const caretPos = input.selectionStart ?? input.value.length; const { value: nextValue, cursor } = insertTopicSearchQualifierAt(value, caretPos, qualifier); applyInputUpdate(nextValue, cursor); }, [applyInputUpdate, value] ); const insertValue = useCallback( (replacement: string) => { const input = inputRef.current; if (!input) return; const caretPos = input.selectionStart ?? input.value.length; const { value: nextValue, cursor } = insertTopicSearchValueAt(value, caretPos, replacement); applyInputUpdate(nextValue, cursor); }, [applyInputUpdate, value] ); const selectAtIndex = useCallback( (index: number) => { if (suggestionMode === 'author') { const hint = authorMatches[index]; if (hint) { insertValue(hint.displayName); } return; } if (suggestionMode === 'title') { const title = titleMatches[index]; if (title) { insertValue(title); } return; } if (suggestionMode === 'qualifier') { const qualifier = qualifierMatches[index]; if (qualifier) { insertQualifier(qualifier); } } }, [authorMatches, insertQualifier, insertValue, qualifierMatches, suggestionMode, titleMatches] ); const selectHighlighted = useCallback(() => { selectAtIndex(highlightIndex); }, [highlightIndex, selectAtIndex]); const moveHighlight = useCallback( (delta: number) => { if (!suggestionCount) return; setHighlightIndex((idx) => (idx + delta + suggestionCount) % suggestionCount); }, [suggestionCount] ); const handleKeyDown: KeyboardEventHandler = (event) => { const pillsVisible = showPills && suggestionCount > 0; if (pillsVisible && (event.key === 'ArrowRight' || event.key === 'ArrowDown')) { event.preventDefault(); moveHighlight(1); return; } if (pillsVisible && (event.key === 'ArrowLeft' || event.key === 'ArrowUp')) { event.preventDefault(); moveHighlight(-1); return; } if ( pillsVisible && event.key === 'Enter' && (suggestionMode === 'author' || suggestionMode === 'title' || (suggestionMode === 'qualifier' && qualifierPartial)) ) { event.preventDefault(); selectHighlighted(); return; } if (event.key === 'Escape' && pillsVisible) { event.preventDefault(); setTabPartial(''); setHighlightIndex(0); inputRef.current?.blur(); return; } if (event.key !== 'Tab' || event.altKey || event.ctrlKey || event.metaKey) { return; } if (!pillsVisible) { return; } event.preventDefault(); const tabKey = suggestionMode === 'author' ? `author:${valueContext?.partial ?? ''}` : suggestionMode === 'title' ? `title:${valueContext?.partial ?? ''}` : `qualifier:${qualifierPartial}`; const nextIndex = tabKey === tabPartial ? (highlightIndex + 1) % suggestionCount : 0; setTabPartial(tabKey); setHighlightIndex(nextIndex); selectAtIndex(nextIndex); }; const syncCaret = () => { const input = inputRef.current; if (!input) return; setCaret(input.selectionStart ?? input.value.length); }; const pillsLabel = suggestionMode === 'author' ? 'Author suggestions' : suggestionMode === 'title' ? 'Title suggestions' : 'Search qualifiers'; const matchKeys = new Set(qualifierMatches.map((item) => item.key)); return (
{ onChange(event.target.value); syncCaret(); }} onFocus={() => { setFocused(true); syncCaret(); }} onBlur={() => { window.setTimeout(() => setFocused(false), 120); }} onClick={syncCaret} onKeyUp={syncCaret} onKeyDown={handleKeyDown} placeholder="Search posts…" title="Examples: intitle:release inpost:bug inthread:thanks from:alice" aria-label="Search posts" aria-controls="forumSearchPills" autoComplete="off" />
); }