404 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|