Skip to content

Commit eb32b11

Browse files
committed
Add package search suggestions using datalist
1 parent 63bba4d commit eb32b11

File tree

3 files changed

+80
-0
lines changed

3 files changed

+80
-0
lines changed

internal/ui/packagelist/templates.templ

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,12 +57,14 @@ templ PackagesContent(list *packages.PackagePopularityList, query string, curren
5757
name="query"
5858
placeholder="Search packages"
5959
value={ query }
60+
list="package-suggestions"
6061
/>
6162
if compare != "" {
6263
<input type="hidden" name="compare" value={ compare }/>
6364
}
6465
<button class="btn btn-primary d-none d-sm-block" type="submit">Search</button>
6566
</div>
67+
<datalist id="package-suggestions"></datalist>
6668
</form>
6769
if len(list.PackagePopularities) > 0 {
6870
@PackageTable(list.PackagePopularities, query, currentOffset, limit, compare)

src/main.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ import "./components/country-map.ts";
55
import "./compare-packages-redirect";
66
import "./packages-redirect";
77
import "./tab-nav";
8+
import "./package-search-suggest";

src/package-search-suggest.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
const DEBOUNCE_MS = 200;
2+
const MIN_QUERY_LENGTH = 2;
3+
const SUGGESTION_LIMIT = 10;
4+
5+
const input = document.querySelector<HTMLInputElement>("#package-search");
6+
const datalist = document.querySelector<HTMLDataListElement>(
7+
"#package-suggestions",
8+
);
9+
10+
if (input && datalist) {
11+
let controller: AbortController | null = null;
12+
let timer: ReturnType<typeof setTimeout>;
13+
let previousValue = input.value;
14+
15+
const isSuggestion = (query: string): boolean =>
16+
datalist.querySelector(`option[value="${CSS.escape(query)}"]`) !== null;
17+
18+
const isSelection = (query: string): boolean => {
19+
const lengthDiff = query.length - previousValue.length;
20+
return lengthDiff > 1 && isSuggestion(query);
21+
};
22+
23+
const fetchSuggestions = async (
24+
query: string,
25+
signal: AbortSignal,
26+
): Promise<string[]> => {
27+
const res = await fetch(
28+
`/api/packages?query=${encodeURIComponent(query)}&limit=${SUGGESTION_LIMIT}`,
29+
{ signal },
30+
);
31+
const data = await res.json();
32+
return data.packagePopularities.map((p: { name: string }) => p.name);
33+
};
34+
35+
const updateDatalist = (names: string[]): void => {
36+
datalist.replaceChildren(
37+
...names.map((name) => {
38+
const option = document.createElement("option");
39+
option.value = name;
40+
return option;
41+
}),
42+
);
43+
};
44+
45+
input.addEventListener("input", () => {
46+
clearTimeout(timer);
47+
const query = input.value.trim();
48+
49+
if (isSelection(query)) {
50+
input.form?.submit();
51+
return;
52+
}
53+
54+
previousValue = query;
55+
56+
if (query.length < MIN_QUERY_LENGTH) {
57+
updateDatalist([]);
58+
return;
59+
}
60+
61+
timer = setTimeout(async () => {
62+
controller?.abort();
63+
controller = new AbortController();
64+
65+
try {
66+
updateDatalist(
67+
await fetchSuggestions(query, controller.signal),
68+
);
69+
} catch (e) {
70+
if (e instanceof DOMException && e.name === "AbortError") {
71+
return;
72+
}
73+
throw e;
74+
}
75+
}, DEBOUNCE_MS);
76+
});
77+
}

0 commit comments

Comments
 (0)