feat: Integrate forum layout support across various components and enhance room handling logic
This commit is contained in:
403
src/app/features/forum/ForumTopicSearchInput.tsx
Normal file
403
src/app/features/forum/ForumTopicSearchInput.tsx
Normal file
@@ -0,0 +1,403 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user