Files
cinny/src/app/features/forum/ForumTopicSearchInput.tsx

404 lines
13 KiB
TypeScript

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<HTMLInputElement>(null);
const mirrorRef = useRef<HTMLSpanElement>(null);
const pillsRef = useRef<HTMLDivElement>(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<HTMLInputElement> = (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 (
<div className={t.ForumSearchField}>
<div className={classNames(t.ForumSearchRow, t.ForumSearchRowAnchor)}>
<span ref={mirrorRef} className={t.ForumSearchCaretMirror} aria-hidden />
<input
ref={inputRef}
className={t.ForumSearchInput}
type="search"
name="q"
value={value}
onChange={(event) => {
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"
/>
<div
ref={pillsRef}
id="forumSearchPills"
className={t.ForumSearchPills}
role="listbox"
aria-label={pillsLabel}
hidden={!showPills}
>
<div className={t.ForumSearchPillsInner}>
{suggestionMode === 'author' &&
authorMatches.map((hint, index) => (
<button
key={hint.userId}
type="button"
role="option"
aria-selected={index === highlightIndex}
className={classNames(t.ForumSearchPill, t.ForumSearchPillValue, {
[t.ForumSearchPillHighlighted]: index === highlightIndex,
})}
title={`${hint.displayName} (${hint.userId})`}
onMouseDown={(event) => event.preventDefault()}
onClick={() => insertValue(hint.displayName)}
>
{hint.displayName}
<span style={{ opacity: 0.65 }}> @{hint.localpart}</span>
</button>
))}
{suggestionMode === 'title' &&
titleMatches.map((title, index) => (
<button
key={title}
type="button"
role="option"
aria-selected={index === highlightIndex}
className={classNames(t.ForumSearchPill, t.ForumSearchPillValue, {
[t.ForumSearchPillHighlighted]: index === highlightIndex,
})}
title={title}
onMouseDown={(event) => event.preventDefault()}
onClick={() => insertValue(title)}
>
{title}
</button>
))}
{suggestionMode === 'qualifier' &&
TOPIC_SEARCH_QUALIFIERS.map((qualifier) => {
const visible = !qualifierPartial || matchKeys.has(qualifier.key);
const matchIndex = qualifierMatches.findIndex((item) => item.key === qualifier.key);
const highlighted = visible && matchIndex === highlightIndex;
const inUse = Boolean(parsed.qualifiers[qualifier.key]?.length);
return (
<button
key={qualifier.key}
type="button"
role="option"
aria-selected={highlighted}
className={classNames(t.ForumSearchPill, {
[t.ForumSearchPillHighlighted]: highlighted,
[t.ForumSearchPillInUse]: inUse,
})}
data-qualifier={qualifier.key}
title={qualifier.label}
aria-label={`${qualifier.label} (${qualifier.token})`}
hidden={!visible}
onMouseDown={(event) => event.preventDefault()}
onClick={() => insertQualifier(qualifier)}
>
{qualifier.token}
</button>
);
})}
</div>
</div>
<IconButton
className={t.ForumSearchSubmit}
type="submit"
variant="SurfaceVariant"
fill="None"
size="300"
radii="300"
aria-label="Search"
>
<Icon src={Icons.Search} size="200" />
</IconButton>
</div>
</div>
);
}