Skip to content

Commit 308732a

Browse files
committed
feat: add NpmPackageSearch dialog implemented in svelte
1 parent dd25ada commit 308732a

File tree

6 files changed

+206
-5
lines changed

6 files changed

+206
-5
lines changed

lib/mod.test.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,21 @@
11
import { expect, test } from "vitest";
22
import { mod } from "./mod";
33

4-
test("mod", async () => {
5-
expect(mod(5, 3)).toBe(2);
4+
test("mod", () => {
5+
expect(mod(-4, 3)).toBe(2);
6+
expect(mod(-3, 3)).toBe(0);
7+
expect(mod(-2, 3)).toBe(1);
8+
expect(mod(-1, 3)).toBe(2);
9+
expect(mod(0, 3)).toBe(0);
10+
expect(mod(1, 3)).toBe(1);
11+
expect(mod(2, 3)).toBe(2);
12+
expect(mod(3, 3)).toBe(0);
13+
expect(mod(4, 3)).toBe(1);
14+
});
15+
16+
test("silence error", () => {
17+
expect(mod(-1, 0)).toBe(0);
18+
expect(mod(0, 0)).toBe(0);
19+
expect(mod(1, 0)).toBe(0);
20+
expect(mod(5, 0)).toBe(0);
621
});

lib/mod.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,4 @@
1-
export const mod = (a: number, b: number): number => ((a % b) + b) % b;
1+
export function mod(a: number, b: number): number {
2+
if (b === 0) return 0;
3+
return ((a % b) + b) % b;
4+
}

lib/scroll-into-view.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export function scrollIntoView(list: HTMLUListElement | undefined, index: number) {
2+
list?.children[index]?.scrollIntoView({ block: "nearest" });
3+
}
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
<script lang="ts">
2+
import { resource, useMutationObserver, watch } from "runed";
3+
import MaterialSymbolsCloseRounded from "~icons/material-symbols/close-rounded";
4+
import { mod } from "../../lib/mod";
5+
import { scrollIntoView } from "../../lib/scroll-into-view";
6+
7+
type NpmPackage = { name: string; description: string };
8+
type NpmSearchResponse = { objects: { package: { name: string; description?: string } }[] };
9+
10+
let dialog = $state<HTMLDialogElement>();
11+
let resultsList = $state<HTMLUListElement>();
12+
13+
let query = $state("");
14+
let resultsCursor = $state(0);
15+
16+
let results = resource(
17+
() => query,
18+
async (query, _, { signal }): Promise<NpmPackage[]> => {
19+
let text = query.trim();
20+
if (text.length < 2) return [];
21+
let endpoint = `https://registry.npmjs.org/-/v1/search?text=${text}&size=20`;
22+
let response = await fetch(endpoint, { signal });
23+
let json = (await response.json()) as NpmSearchResponse;
24+
return json.objects.map(({ package: pkg }) => ({
25+
name: pkg.name,
26+
description: pkg.description ?? "Description not available",
27+
}));
28+
},
29+
{ lazy: true, initialValue: [], debounce: 400 },
30+
);
31+
32+
// Track when the dialog is opened or closed.
33+
// Workaround as the ToggleEvent is not supported for dialogs in Safari.
34+
// See https://developer.mozilla.org/en-US/docs/Web/API/ToggleEvent
35+
// and https://github.com/sveltejs/svelte/issues/4723.
36+
useMutationObserver(
37+
() => dialog,
38+
() => {
39+
resetSearchState();
40+
},
41+
{ attributeFilter: ["open"] },
42+
);
43+
44+
// When results change, move the cursor to the first result.
45+
watch(
46+
() => results.current,
47+
() => {
48+
resultsCursor = 0;
49+
},
50+
);
51+
52+
// When the cursor moves, scroll the search result into view.
53+
watch(
54+
() => resultsCursor,
55+
() => {
56+
scrollIntoView(resultsList, resultsCursor);
57+
},
58+
);
59+
60+
function resetSearchState() {
61+
query = "";
62+
resultsCursor = 0;
63+
}
64+
65+
function handleWindowKeydown(event: KeyboardEvent) {
66+
if (
67+
// Open the dialog with Ctrl+Shift+F or Cmd+Shift+F.
68+
(event.ctrlKey || event.metaKey) &&
69+
event.shiftKey &&
70+
event.key.toLowerCase() === "f" &&
71+
dialog !== undefined &&
72+
!dialog.open
73+
) {
74+
event.preventDefault();
75+
dialog.showModal();
76+
}
77+
}
78+
79+
function handleInputKeydown(event: KeyboardEvent) {
80+
switch (event.key) {
81+
case "ArrowUp":
82+
event.preventDefault();
83+
prevResult();
84+
return;
85+
case "ArrowDown":
86+
event.preventDefault();
87+
nextResult();
88+
return;
89+
case "Enter":
90+
event.preventDefault();
91+
useResult();
92+
return;
93+
}
94+
}
95+
96+
function prevResult() {
97+
resultsCursor = mod(resultsCursor - 1, results.current.length);
98+
}
99+
100+
function nextResult() {
101+
resultsCursor = mod(resultsCursor + 1, results.current.length);
102+
}
103+
104+
function useResult() {
105+
let pkgName = results.current[resultsCursor]?.name;
106+
if (!pkgName) return;
107+
closeDialog();
108+
window.location.href = `/package/${pkgName}`;
109+
}
110+
111+
function closeDialog() {
112+
dialog?.close();
113+
}
114+
</script>
115+
116+
<svelte:window onkeydown={handleWindowKeydown} />
117+
118+
<!-- Remove transition to let search input autofocus; see https://github.com/saadeghi/daisyui/issues/3440 -->
119+
<dialog bind:this={dialog} class="modal [transition:unset]">
120+
<div class="modal-box">
121+
<form method="dialog">
122+
<button class="btn btn-sm btn-circle btn-ghost absolute top-2 right-2">
123+
<MaterialSymbolsCloseRounded class="size-6" />
124+
</button>
125+
</form>
126+
127+
<h3 class="text-lg font-bold">Search npm packages</h3>
128+
129+
<label for="npm-package-search-input" class="sr-only">Search packages on npm</label>
130+
<!-- svelte-ignore a11y_autofocus -->
131+
<input
132+
id="npm-package-search-input"
133+
type="search"
134+
autocomplete="off"
135+
class="input w-full"
136+
bind:value={query}
137+
onkeydown={handleInputKeydown}
138+
autofocus
139+
/>
140+
141+
<div class="mt-4 h-64 overflow-hidden overflow-y-auto">
142+
{#if results.loading}
143+
<div>Searching npm...</div>
144+
{:else if results.error}
145+
<div>Error searching npm</div>
146+
{:else if results.current.length > 0}
147+
<ul bind:this={resultsList}>
148+
{#each results.current as result, i (result.name)}
149+
<li>
150+
<a
151+
class={[
152+
"hover:bg-base-content hover:text-base-300 flex flex-col rounded px-2 py-1",
153+
i === resultsCursor && "bg-base-content text-base-300",
154+
]}
155+
href="/package/{result.name}"
156+
onclick={closeDialog}
157+
>
158+
<span class="truncate font-bold">{result.name}</span>
159+
<span class="truncate text-sm">{result.description}</span>
160+
</a>
161+
</li>
162+
{/each}
163+
</ul>
164+
{:else if results.current.length === 0 && query.length > 0}
165+
<div
166+
class="bg-base-content text-base-300 flex items-center justify-between gap-4 rounded px-2 py-1"
167+
>
168+
<span class="truncate">No results</span>
169+
<span class="opacity-70">¯\_(ツ)_/¯</span>
170+
</div>
171+
{/if}
172+
</div>
173+
</div>
174+
175+
<form method="dialog" class="modal-backdrop">
176+
<button>Close npm package search</button>
177+
</form>
178+
</dialog>

src/components/PackageSearchOpener.astro

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@
22
import LucideSearch from "~icons/lucide/search";
33
---
44

5+
<!-- @keydown.ctrl.shift.f.window.prevent="open()"
6+
@keydown.cmd.shift.f.window.prevent="open()" -->
57
<button
68
x-data="dialogOpener('#package-search')"
79
class="btn btn-outline border-base-content/20 w-full justify-between"
810
@click="open()"
9-
@keydown.ctrl.shift.f.window.prevent="open()"
10-
@keydown.cmd.shift.f.window.prevent="open()"
1111
>
1212
<div class="text-opacity-20 flex items-center gap-2">
1313
<LucideSearch class="size-4" />

src/layouts/BaseLayout.astro

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { SEO } from "astro-seo";
33
import Footer from "../components/Footer.astro";
44
import Header from "../components/Header.astro";
5+
import NpmPackageSearch from "../components/NpmPackageSearch.svelte";
56
import PackageSearch from "../components/PackageSearch.astro";
67
import "../styles/global.css";
78
import "../styles/shiki.css";
@@ -62,6 +63,7 @@ const titleTemplate = title !== "jsDocs.io" ? "%s - jsDocs.io" : undefined;
6263

6364
<slot name="modals" />
6465
<PackageSearch />
66+
<NpmPackageSearch client:load />
6567

6668
<script src="../scripts/add-copy-buttons.ts"></script>
6769
</body>

0 commit comments

Comments
 (0)