Find the cost of adding an npm package to your app's bundle size teardown.kelinci.dev
14
fork

Configure Feed

Select the types of activity you want to include in your feed.

at trunk 315 lines 8.2 kB view raw
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