返回 CodeWhale
faq-search.tsx
根目录 / web / components / faq-search.tsx
1 "use client";
2
3 import { useState, useMemo, useRef, useCallback, useEffect } from "react";
4 import { faqSourceHref } from "@/lib/faq-source";
5 import { fill, getFaq } from "@/lib/i18n/dictionaries";
6 import { extractText } from "@/lib/react-text";
7 import { highlightSpan } from "@/lib/search-utils";
8 import { isSlashShortcut } from "@/lib/slash-shortcut";
9 import { Icon } from "./icon";
10 import { WhalePose } from "./whale-pose";
11
12 export interface FaqSearchItem {
13 q: string;
14 a: React.ReactNode;
15 sources?: string[];
16 }
17
18 /* ------------------------------------------------------------------ */
19 /* Highlight helper */
20 /* ------------------------------------------------------------------ */
21
22 function highlight(text: string, query: string): React.ReactNode {
23 // Index arithmetic lives in search-utils: lowercasing can change a
24 // string's length, so `text` cannot be sliced with indices taken from
25 // its lowercased copy.
26 const span = highlightSpan(text, query);
27 if (!span) return text;
28 return (
29 <>
30 {span.before}
31 <mark className="search-highlight">{span.match}</mark>
32 {span.after}
33 </>
34 );
35 }
36
37 /* ------------------------------------------------------------------ */
38 /* Component */
39 /* ------------------------------------------------------------------ */
40
41 export function FaqSearch({
42 items,
43 locale,
44 }: {
45 items: FaqSearchItem[];
46 locale: string;
47 }) {
48 const t = getFaq(locale);
49 const [query, setQuery] = useState("");
50 const inputRef = useRef<HTMLInputElement>(null);
51
52 // Precompute haystack for each item (question + answer text + sources).
53 const haystacks = useMemo(
54 () =>
55 items.map((item) => {
56 const parts = [
57 item.q,
58 extractText(item.a),
59 ...(item.sources ?? []),
60 ];
61 return parts.join(" ").toLowerCase();
62 }),
63 [items],
64 );
65
66 const filtered = useMemo(() => {
67 const q = query.trim().toLowerCase();
68 if (!q) return items.map((item, i) => ({ item, i }));
69 return items
70 .map((item, i) => ({ item, i }))
71 .filter(({ i }) => haystacks[i].includes(q));
72 }, [query, haystacks, items]);
73
74 // Keyboard shortcut: focus search on an unmodified "/" typed outside a field.
75 const handleKeyDown = useCallback((e: KeyboardEvent) => {
76 if (isSlashShortcut(e)) {
77 e.preventDefault();
78 inputRef.current?.focus();
79 }
80 }, []);
81
82 useEffect(() => {
83 window.addEventListener("keydown", handleKeyDown);
84 return () => window.removeEventListener("keydown", handleKeyDown);
85 }, [handleKeyDown]);
86
87 const total = items.length;
88 const matched = filtered.length;
89 const hasQuery = query.trim().length > 0;
90
91 return (
92 <>
93 <div className="faq-search">
94 <div className="search-field">
95 <Icon name="search" className="search-field-icon" />
96 <input
97 ref={inputRef}
98 type="search"
99 value={query}
100 onChange={(e) => setQuery(e.target.value)}
101 placeholder={t.searchPlaceholder}
102 className="search-input"
103 aria-label={t.searchLabel}
104 aria-keyshortcuts="/"
105 />
106 {hasQuery ? (
107 <button
108 type="button"
109 onClick={() => setQuery("")}
110 className="nav-icon-button nav-icon-button-sm search-field-clear"
111 aria-label={t.searchClear}
112 title={t.searchClear}
113 >
114 <Icon name="x" className="nav-icon" />
115 </button>
116 ) : (
117 <kbd className="search-field-kbd" aria-hidden="true">/</kbd>
118 )}
119 </div>
120 <p className="faq-search-count" aria-live="polite">
121 {hasQuery
122 ? matched > 0
123 ? fill(t.searchMatches, { matched, total, query: query.trim() })
124 : fill(t.searchNoMatches, { query: query.trim() })
125 : ""}
126 </p>
127 </div>
128
129 {matched > 0 ? (
130 <div className="group-card">
131 {filtered.map(({ item, i }) => (
132 <details key={i} className="disclosure-row">
133 <summary>
134 <span className="faq-index tabular" aria-hidden="true">{i + 1}</span>
135 <span className="faq-question">{highlight(item.q, query)}</span>
136 <Icon name="chevron-down" className="disclosure-chevron" />
137 </summary>
138 <div className={`disclosure-body prose faq-answer ${t.answerClassName}`.trimEnd()}>
139 <div>{item.a}</div>
140 {item.sources && item.sources.length > 0 && (
141 <p className="faq-sources">
142 <span>{t.sourcesLabel}:</span>
143 {item.sources.map((s) => {
144 const href = faqSourceHref(s);
145 return href ? (
146 <a key={s} href={href} target="_blank" rel="noreferrer">
147 {s}
148 </a>
149 ) : (
150 <code key={s}>{s}</code>
151 );
152 })}
153 </p>
154 )}
155 </div>
156 </details>
157 ))}
158 </div>
159 ) : (
160 <div className="empty-state" role="status">
161 <WhalePose pose="search" />
162 <p className="empty-state-title">{t.noResultsTitle}</p>
163 <p className="empty-state-reason">{t.noResultsBody}</p>
164 </div>
165 )}
166 </>
167 );
168 }
169
169 lines Plain Text