Find the cost of adding an npm package to your app's bundle size
teardown.kelinci.dev
1import { createEffect, createMemo, createSignal, For, onMount, Show } from 'solid-js';
2
3import { LucideLoader, LucidePackage, LucideSearch } from '../icons/lucide';
4import { modality } from '../lib/modality';
5import { formatPackageSpecifier, parsePackageSpecifier, type Registry } from '../lib/package-name';
6import { createQuery } from '../lib/query';
7import { scrollIntoContainerView } from '../lib/scroll';
8import { createTrailingThrottle, makeAbortable } from '../lib/signals';
9import { normalizeWhitespace } from '../lib/strings';
10import Input from '../primitives/input';
11
12// #region types
13
14interface SearchResult {
15 name: string;
16 version: string;
17 description?: string;
18 registry: Registry;
19}
20
21interface NpmSearchResponse {
22 objects: Array<{
23 package: {
24 name: string;
25 version: string;
26 description?: string;
27 };
28 }>;
29}
30
31interface JsrSearchResponse {
32 items: Array<{
33 scope: string;
34 name: string;
35 latestVersion: string;
36 description?: string;
37 }>;
38}
39
40// #endregion
41
42// #region search API
43
44async function searchNpm(query: string, signal: AbortSignal): Promise<SearchResult[]> {
45 const url = `https://registry.npmjs.org/-/v1/search?text=${encodeURIComponent(query)}&size=10`;
46 const response = await fetch(url, { signal });
47 if (!response.ok) {
48 return [];
49 }
50
51 // oxlint-disable-next-line typescript/no-unsafe-type-assertion
52 const data = (await response.json()) as NpmSearchResponse;
53
54 return data.objects.map((obj) => ({
55 name: obj.package.name,
56 version: obj.package.version,
57 description: obj.package.description,
58 registry: 'npm' as const,
59 }));
60}
61
62async function searchJsr(query: string, signal: AbortSignal): Promise<SearchResult[]> {
63 const url = `https://api.jsr.io/packages?query=${encodeURIComponent(query)}&limit=10`;
64 const response = await fetch(url, { signal });
65 if (!response.ok) {
66 return [];
67 }
68
69 // oxlint-disable-next-line typescript/no-unsafe-type-assertion
70 const data = (await response.json()) as JsrSearchResponse;
71
72 return data.items.map((item) => ({
73 name: `@${item.scope}/${item.name}`,
74 version: item.latestVersion,
75 description: item.description,
76 registry: 'jsr' as const,
77 }));
78}
79
80interface ParsedQuery {
81 registry: Registry | null;
82 query: string;
83}
84
85function parseQuery(input: string): ParsedQuery {
86 const trimmed = input.trim();
87 if (trimmed.startsWith('npm:')) {
88 return { registry: 'npm', query: trimmed.slice(4).trim() };
89 }
90 if (trimmed.startsWith('jsr:')) {
91 return { registry: 'jsr', query: trimmed.slice(4).trim() };
92 }
93 return { registry: null, query: trimmed };
94}
95
96// #endregion
97
98// #region component
99
100interface PackageSearchInputProps {
101 value: string;
102 onChange: (next: string) => void;
103 onSelect?: (specifier: string) => void;
104 autofocus?: boolean;
105 disabled?: boolean;
106}
107
108const PackageSearchInput = (props: PackageSearchInputProps) => {
109 let listboxRef: HTMLDivElement | undefined;
110
111 const [createAbortSignal] = makeAbortable();
112
113 const [open, setOpen] = createSignal(false);
114 const [activeIndex, setActiveIndex] = createSignal(-1);
115
116 const throttledValue = createTrailingThrottle(() => normalizeWhitespace(props.value), 500);
117 const parsed = createMemo(() => parseQuery(props.value));
118
119 const [results] = createQuery(
120 () => {
121 const { registry, query } = parseQuery(throttledValue());
122 if (query.length < 3) {
123 return null;
124 }
125
126 return { registry, query };
127 },
128 async ({ registry, query }) => {
129 const signal = createAbortSignal();
130 if (registry === 'npm') {
131 return searchNpm(query, signal);
132 }
133 if (registry === 'jsr') {
134 return searchJsr(query, signal);
135 }
136 // default to npm when no prefix
137 return searchNpm(query, signal);
138 },
139 );
140
141 const showPopover = () => !props.disabled && open() && parsed().query.length >= 2;
142
143 createEffect(() => {
144 if (props.disabled) {
145 setOpen(false);
146 setActiveIndex(-1);
147 }
148 });
149
150 const handleSelect = (result: SearchResult) => {
151 const specifier = formatPackageSpecifier({
152 registry: result.registry,
153 name: result.name,
154 range: 'latest',
155 });
156 props.onChange(specifier);
157 props.onSelect?.(specifier);
158 setOpen(false);
159 setActiveIndex(-1);
160 };
161
162 const handleKeyDown = (ev: KeyboardEvent) => {
163 if (props.disabled) {
164 return;
165 }
166
167 const items = results() ?? [];
168
169 switch (ev.key) {
170 case 'ArrowDown': {
171 if (items.length === 0) {
172 return;
173 }
174 ev.preventDefault();
175 setActiveIndex((i) => (i + 1) % items.length);
176 break;
177 }
178 case 'ArrowUp': {
179 if (items.length === 0) {
180 return;
181 }
182 ev.preventDefault();
183 setActiveIndex((i) => (i <= 0 ? items.length - 1 : i - 1));
184 break;
185 }
186 case 'Enter': {
187 ev.preventDefault();
188 const idx = activeIndex();
189 if (idx >= 0 && idx < items.length) {
190 handleSelect(items[idx]!);
191 } else {
192 const parsed = parsePackageSpecifier(props.value.trim());
193 if (parsed) {
194 props.onSelect?.(formatPackageSpecifier(parsed));
195 setOpen(false);
196 setActiveIndex(-1);
197 }
198 }
199 break;
200 }
201 case 'Escape': {
202 ev.preventDefault();
203 setOpen(false);
204 setActiveIndex(-1);
205 break;
206 }
207 }
208 };
209
210 return (
211 <div class="relative flex flex-col">
212 <Input
213 inputRef={(node) => {
214 onMount(() => {
215 if (props.autofocus) {
216 node.focus();
217 }
218 });
219 }}
220 disabled={props.disabled}
221 value={props.value}
222 onInput={(ev) => {
223 props.onChange(ev.currentTarget.value);
224 setOpen(true);
225 setActiveIndex(-1);
226 }}
227 onFocus={() => setOpen(true)}
228 onBlur={() => setOpen(false)}
229 onKeyDown={handleKeyDown}
230 placeholder="Search packages (npm: or jsr:)"
231 role="combobox"
232 aria-expanded={showPopover()}
233 aria-autocomplete="list"
234 contentBefore={
235 <Show
236 when={results.state === 'pending' || results.state === 'refreshing'}
237 fallback={<LucideSearch />}
238 >
239 <LucideLoader class="animate-spin-linear" />
240 </Show>
241 }
242 />
243
244 {/* listbox */}
245 <Show when={showPopover()}>
246 <div
247 ref={(el) => (listboxRef = el)}
248 class="absolute top-full right-0 left-0 z-10 mt-0.5 flex max-h-80 flex-col gap-0.5 overflow-y-auto rounded-md bg-neutral-background-1 p-1 text-base-300 shadow-16"
249 role="listbox"
250 tabindex={-1}
251 onMouseDown={(e) => e.preventDefault()}
252 >
253 <Show
254 when={results() && results()!.length > 0}
255 fallback={
256 <div class="px-2 py-1.5 text-base-300 text-neutral-foreground-3">
257 {results.loading ? 'Searching...' : 'No packages found'}
258 </div>
259 }
260 >
261 <For each={results()}>
262 {(result, index) => (
263 <div
264 ref={(el) => {
265 createEffect(() => {
266 if (activeIndex() === index() && modality() === 'keyboard' && listboxRef) {
267 scrollIntoContainerView(listboxRef, el);
268 }
269 });
270 }}
271 role="option"
272 aria-selected={activeIndex() === index()}
273 class="flex gap-2 rounded-md px-2 py-1.5 text-base-300 text-neutral-foreground-1 select-none"
274 classList={{
275 'bg-neutral-background-1-hover': activeIndex() === index(),
276 'hover:bg-neutral-background-1-hover active:bg-neutral-background-1-pressed':
277 modality() === 'pointer',
278 }}
279 onMouseOver={() => modality() === 'pointer' && setActiveIndex(index())}
280 onClick={() => handleSelect(result)}
281 >
282 <div class="grid size-5 shrink-0 place-items-center text-neutral-foreground-3">
283 <LucidePackage class="size-4" />
284 </div>
285
286 <div class="flex min-w-0 grow flex-col">
287 <div class="flex gap-1">
288 {result.registry === 'jsr' && (
289 <span class="font-medium text-neutral-foreground-3">jsr:</span>
290 )}
291
292 <span class="min-w-0 wrap-break-word">{result.name}</span>
293
294 <span class="my-0.5 shrink-0 text-base-200 text-neutral-foreground-3">
295 {result.version}
296 </span>
297 </div>
298
299 <span class="line-clamp-2 text-base-200 text-neutral-foreground-3 empty:hidden">
300 {result.description ?? ''}
301 </span>
302 </div>
303 </div>
304 )}
305 </For>
306 </Show>
307 </div>
308 </Show>
309 </div>
310 );
311};
312
313export default PackageSearchInput;
314
315// #endregion