Skip to content

Commit 90e496d

Browse files
committed
feat: autocomplete tags
1 parent 2eb33bd commit 90e496d

File tree

1 file changed

+111
-8
lines changed

1 file changed

+111
-8
lines changed

src/components/Search/index.tsx

Lines changed: 111 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import React, { useContext, useEffect, useRef, useState, useTransition } from "react";
1+
import React, { useContext, useEffect, useMemo, useRef, useState, useTransition } from "react";
22
import { useLocation, useNavigate } from "react-router-dom";
33
import { styled } from "@linaria/react";
44
import { SearchContext } from "./SearchContext";
5+
import { DeclarationsContext } from "../Docs/DeclarationsContext";
56
import { KindIcon, IconKind } from "../KindIcon";
67

78
export function useCtrlFHook<T extends HTMLElement>() {
@@ -60,7 +61,7 @@ function getLastWord(input: string): string {
6061
return input.split(" ").at(-1) ?? "";
6162
}
6263

63-
function shouldShowPopup(input: string): boolean {
64+
function shouldShowFirstLevelPopup(input: string): boolean {
6465
return !getLastWord(input).includes(":");
6566
}
6667

@@ -76,6 +77,20 @@ function insertTag(inputValue: string, tag: string): string {
7677
return parts.join(" ");
7778
}
7879

80+
function getSecondLevelContext(lastWord: string): { type: "module" | "metadata"; value: string } | null {
81+
const lower = lastWord.toLowerCase();
82+
if (lower.startsWith("module:")) return { type: "module", value: lastWord.slice(7) };
83+
if (lower.startsWith("metadata:")) return { type: "metadata", value: lastWord.slice(9) };
84+
return null;
85+
}
86+
87+
function insertValue(inputValue: string, tagPrefix: string, value: string): string {
88+
if (inputValue === "" || inputValue.endsWith(" ")) return inputValue + tagPrefix + value;
89+
const parts = inputValue.split(" ");
90+
parts[parts.length - 1] = tagPrefix + value;
91+
return parts.join(" ");
92+
}
93+
7994
const SearchBoxWrapper = styled.div`
8095
position: relative;
8196
width: 100%;
@@ -145,6 +160,39 @@ const TagItemExample = styled.span`
145160
flex-shrink: 0;
146161
`;
147162

163+
const ValuePopupList = styled.div`
164+
max-height: 200px;
165+
overflow-y: auto;
166+
`;
167+
168+
function ValueSuggestPopup({
169+
header, values, activeIndex, onSelect,
170+
}: {
171+
header: string;
172+
values: string[];
173+
activeIndex: number;
174+
onSelect: (value: string) => void;
175+
}) {
176+
return (
177+
<TagPopup role="listbox" aria-label={header}>
178+
<TagPopupHeader>{header}</TagPopupHeader>
179+
<ValuePopupList>
180+
{values.map((v, i) => (
181+
<TagItem
182+
key={v}
183+
role="option"
184+
aria-selected={i === activeIndex}
185+
data-active={i === activeIndex || undefined}
186+
onMouseDown={(e) => { e.preventDefault(); onSelect(v); }}
187+
>
188+
<TagItemName>{v}</TagItemName>
189+
</TagItem>
190+
))}
191+
</ValuePopupList>
192+
</TagPopup>
193+
);
194+
}
195+
148196
function SearchTagPopup({
149197
tags, activeIndex, onSelect,
150198
}: {
@@ -185,6 +233,7 @@ export function SearchBox({
185233
placeholder?: string;
186234
}) {
187235
const { search, setSearch } = useContext(SearchContext);
236+
const { declarations } = useContext(DeclarationsContext);
188237
const [inputValue, setInputValue] = useState(search);
189238
const [isFocused, setIsFocused] = useState(false);
190239
const [activeIndex, setActiveIndex] = useState(0);
@@ -207,12 +256,44 @@ export function SearchBox({
207256
// infinite loop: setSearch triggers a navigate which changes location.search.
208257
}, [location.search]); // eslint-disable-line react-hooks/exhaustive-deps
209258

259+
const uniqueModules = useMemo(() => {
260+
const set = new Set<string>();
261+
for (const d of declarations) set.add(d.module);
262+
return [...set].sort((a, b) => a.localeCompare(b));
263+
}, [declarations]);
264+
265+
const uniqueMetadataKeys = useMemo(() => {
266+
const set = new Set<string>();
267+
for (const d of declarations) {
268+
for (const m of d.metadata) set.add(m.name);
269+
if (d.kind === "class") {
270+
for (const f of d.fields) for (const m of f.metadata) set.add(m.name);
271+
} else {
272+
for (const mem of d.members) for (const m of mem.metadata) set.add(m.name);
273+
}
274+
}
275+
return [...set].sort((a, b) => a.localeCompare(b));
276+
}, [declarations]);
277+
210278
const lastWord = getLastWord(inputValue);
211279
const filteredTags = filterTags(lastWord);
212-
const showPopup = isFocused && shouldShowPopup(inputValue) && filteredTags.length > 0;
280+
const showFirstLevel = isFocused && shouldShowFirstLevelPopup(inputValue) && filteredTags.length > 0;
213281

214-
const handleTagSelect = (tag: string) => {
215-
const newValue = insertTag(inputValue, tag);
282+
const secondLevel = getSecondLevelContext(lastWord);
283+
const secondLevelValues = useMemo(() => {
284+
if (!secondLevel) return [];
285+
const list = secondLevel.type === "module" ? uniqueModules : uniqueMetadataKeys;
286+
if (secondLevel.value === "") return list;
287+
const lower = secondLevel.value.toLowerCase();
288+
return list.filter(v => v.toLowerCase().startsWith(lower));
289+
}, [secondLevel?.type, secondLevel?.value, uniqueModules, uniqueMetadataKeys]);
290+
const isExactMatch = secondLevel != null && secondLevelValues.length === 1 && secondLevelValues[0].toLowerCase() === secondLevel.value.toLowerCase();
291+
const showSecondLevel = isFocused && secondLevel != null && secondLevelValues.length > 0 && !isExactMatch;
292+
293+
const showPopup = showFirstLevel || showSecondLevel;
294+
const popupLength = showFirstLevel ? filteredTags.length : secondLevelValues.length;
295+
296+
const applyNewValue = (newValue: string) => {
216297
setInputValue(newValue);
217298
ownNavigateRef.current = true;
218299
const replace = inputValue !== "" || newValue === "";
@@ -224,6 +305,16 @@ export function SearchBox({
224305
ref.current?.focus();
225306
};
226307

308+
const handleTagSelect = (tag: string) => {
309+
applyNewValue(insertTag(inputValue, tag));
310+
};
311+
312+
const handleValueSelect = (value: string) => {
313+
if (!secondLevel) return;
314+
const tagPrefix = secondLevel.type === "module" ? "module:" : "metadata:";
315+
applyNewValue(insertValue(inputValue, tagPrefix, value));
316+
};
317+
227318
const onChange: React.ChangeEventHandler<HTMLInputElement> = ({ target: { value } }) => {
228319
const wasSearching = inputValue !== "";
229320
setInputValue(value);
@@ -242,9 +333,13 @@ export function SearchBox({
242333

243334
const onKeyDown: React.KeyboardEventHandler<HTMLInputElement> = (e) => {
244335
if (!showPopup) return;
245-
if (e.key === "ArrowDown") { e.preventDefault(); setActiveIndex(i => Math.min(i + 1, filteredTags.length - 1)); }
336+
if (e.key === "ArrowDown") { e.preventDefault(); setActiveIndex(i => Math.min(i + 1, popupLength - 1)); }
246337
else if (e.key === "ArrowUp") { e.preventDefault(); setActiveIndex(i => Math.max(i - 1, 0)); }
247-
else if (e.key === "Enter" || e.key === "Tab") { if (filteredTags[activeIndex]) { e.preventDefault(); handleTagSelect(filteredTags[activeIndex].tag); } }
338+
else if (e.key === "Enter" || e.key === "Tab") {
339+
e.preventDefault();
340+
if (showFirstLevel && filteredTags[activeIndex]) handleTagSelect(filteredTags[activeIndex].tag);
341+
else if (showSecondLevel && secondLevelValues[activeIndex]) handleValueSelect(secondLevelValues[activeIndex]);
342+
}
248343
else if (e.key === "Escape") { setIsFocused(false); }
249344
};
250345

@@ -268,9 +363,17 @@ export function SearchBox({
268363
autoCorrect="off"
269364
autoCapitalize="off"
270365
/>
271-
{showPopup && (
366+
{showFirstLevel && (
272367
<SearchTagPopup tags={filteredTags} activeIndex={activeIndex} onSelect={handleTagSelect} />
273368
)}
369+
{showSecondLevel && secondLevel && (
370+
<ValueSuggestPopup
371+
header={secondLevel.type === "module" ? "Modules" : "Metadata Keys"}
372+
values={secondLevelValues}
373+
activeIndex={activeIndex}
374+
onSelect={handleValueSelect}
375+
/>
376+
)}
274377
</SearchBoxWrapper>
275378
);
276379
}

0 commit comments

Comments
 (0)