@@ -3,16 +3,18 @@ import { useDebouncedValue } from '@tanstack/react-pacer'
33import { Search } from 'lucide-react'
44import { keepPreviousData , useQuery } from '@tanstack/react-query'
55import { Command } from 'cmdk'
6+ import { twMerge } from 'tailwind-merge'
67import { Spinner } from '~/components/Spinner'
78
89type NpmSearchResult = {
910 name : string
1011 description ?: string
1112 version ?: string
12- label ?: string
1313 publisher ?: { username ?: string }
1414}
1515
16+ const CREATE_ITEM_VALUE = '__create__'
17+
1618export type PackageSearchProps = {
1719 onSelect : ( packageName : string ) => void
1820 placeholder ?: string
@@ -48,110 +50,123 @@ export function PackageSearch({
4850 }
4951 } , [ ] )
5052
53+ const hasUsableQuery = debouncedInputValue . length > 2
54+
5155 const searchQuery = useQuery ( {
5256 queryKey : [ 'npm-search' , debouncedInputValue ] ,
5357 queryFn : async ( ) => {
54- if ( ! debouncedInputValue || debouncedInputValue . length <= 2 )
55- return [ ] as Array < NpmSearchResult >
56-
5758 const response = await fetch (
58- `https://api.npms.io/v2/ search?q =${ encodeURIComponent (
59+ `https://registry.npmjs.org/-/v1/ search?text =${ encodeURIComponent (
5960 debouncedInputValue ,
6061 ) } &size=10`,
6162 )
6263 const data = ( await response . json ( ) ) as {
63- results : Array < { package : NpmSearchResult } >
64+ objects : Array < { package : NpmSearchResult } >
6465 }
65- return data . results . map ( ( r ) => r . package )
66+ return data . objects . map ( ( r ) => r . package )
6667 } ,
67- enabled : debouncedInputValue . length > 2 ,
68+ enabled : hasUsableQuery ,
6869 placeholderData : keepPreviousData ,
6970 } )
7071
71- const results = React . useMemo ( ( ) => {
72- const hasInputValue = searchQuery . data ?. find (
73- ( d ) => d . name === debouncedInputValue ,
74- )
75-
76- return [
77- ...( hasInputValue
78- ? [ ]
79- : [
80- {
81- name : debouncedInputValue ,
82- label : `Use "${ debouncedInputValue } "` ,
83- } ,
84- ] ) ,
85- ...( searchQuery . data ?? [ ] ) ,
86- ]
87- } , [ searchQuery . data , debouncedInputValue ] )
88-
89- const handleInputChange = ( value : string ) => {
90- setInputValue ( value )
91- }
72+ const searchResults = hasUsableQuery ? ( searchQuery . data ?? [ ] ) : [ ]
73+ const trimmedInput = debouncedInputValue . trim ( )
74+ const showCreateItem =
75+ hasUsableQuery &&
76+ trimmedInput . length > 0 &&
77+ ! searchResults . some ( ( d ) => d . name === trimmedInput )
9278
9379 const handleSelect = ( value : string ) => {
94- const selectedItem = results ?. find ( ( item ) => item . name === value )
95- if ( ! selectedItem ) return
96-
97- onSelect ( selectedItem . name )
80+ if ( value === CREATE_ITEM_VALUE ) {
81+ if ( ! trimmedInput ) return
82+ onSelect ( trimmedInput )
83+ } else {
84+ const match = searchResults . find ( ( item ) => item . name === value )
85+ if ( ! match ) return
86+ onSelect ( match . name )
87+ }
9888 setInputValue ( '' )
9989 setOpen ( false )
10090 }
10191
92+ const showList = open && inputValue . length > 0
93+
10294 return (
103- < div className = "flex-1" ref = { containerRef } >
104- < div className = "relative" >
105- < Command className = "w-full" shouldFilter = { false } >
106- < div className = "flex items-center gap-1" >
107- < Search className = "text-lg" />
108- < Command . Input
109- placeholder = { placeholder }
110- className = "w-full bg-gray-500/10 rounded-md px-2 py-1 min-w-[200px] text-sm"
111- value = { inputValue }
112- onValueChange = { handleInputChange }
113- onFocus = { ( ) => setOpen ( true ) }
114- // eslint-disable-next-line jsx-a11y/no-autofocus
115- autoFocus = { autoFocus }
116- />
117- </ div >
95+ < div className = "flex-1 relative" ref = { containerRef } >
96+ < Command
97+ className = "w-full"
98+ shouldFilter = { false }
99+ loop
100+ label = "Search npm packages"
101+ >
102+ < div className = "flex items-center gap-1" >
103+ < Search className = "text-lg" />
104+ < Command . Input
105+ placeholder = { placeholder }
106+ className = "w-full bg-gray-500/10 rounded-md px-2 py-1 min-w-[200px] text-sm"
107+ value = { inputValue }
108+ onValueChange = { setInputValue }
109+ onFocus = { ( ) => setOpen ( true ) }
110+ // eslint-disable-next-line jsx-a11y/no-autofocus
111+ autoFocus = { autoFocus }
112+ />
118113 { searchQuery . isFetching && (
119- < div className = "absolute right-2 top-0 bottom-0 flex items-center justify-center" >
114+ < div className = "absolute right-2 top-0 bottom-0 flex items-center justify-center pointer-events-none " >
120115 < Spinner className = "text-sm" />
121116 </ div >
122117 ) }
123- { inputValue . length && open ? (
124- < Command . List className = "absolute z-10 w-full mt-1 bg-white dark:bg-gray-800 rounded-md shadow-lg max-h-60 overflow-auto divide-y divide-gray-500/10" >
125- { inputValue . length < 3 ? (
126- < div className = "px-3 py-2" > Keep typing to search...</ div >
127- ) : searchQuery . isLoading ? (
128- < div className = "px-3 py-2 flex items-center gap-2" >
129- Searching...
118+ </ div >
119+ < Command . List
120+ className = { twMerge (
121+ 'absolute z-10 w-full mt-1 bg-white dark:bg-gray-800 rounded-md shadow-lg max-h-60 overflow-auto divide-y divide-gray-500/10' ,
122+ ! showList && 'hidden' ,
123+ ) }
124+ >
125+ { inputValue . length < 3 ? (
126+ < div className = "px-3 py-2 text-sm text-gray-500 dark:text-gray-400" >
127+ Keep typing to search...
128+ </ div >
129+ ) : searchQuery . isLoading ? (
130+ < div className = "px-3 py-2 text-sm text-gray-500 dark:text-gray-400" >
131+ Searching...
132+ </ div >
133+ ) : ! searchResults . length && ! showCreateItem ? (
134+ < div className = "px-3 py-2 text-sm text-gray-500 dark:text-gray-400" >
135+ No packages found
136+ </ div >
137+ ) : null }
138+ { showCreateItem && (
139+ < Command . Item
140+ key = { CREATE_ITEM_VALUE }
141+ value = { CREATE_ITEM_VALUE }
142+ onSelect = { handleSelect }
143+ className = "px-3 py-2 cursor-pointer hover:bg-gray-500/20 data-[selected=true]:bg-gray-500/20"
144+ >
145+ < div className = "font-medium" > Use "{ trimmedInput } "</ div >
146+ </ Command . Item >
147+ ) }
148+ { searchResults . map ( ( item ) => (
149+ < Command . Item
150+ key = { item . name }
151+ value = { item . name }
152+ onSelect = { handleSelect }
153+ className = "px-3 py-2 cursor-pointer hover:bg-gray-500/20 data-[selected=true]:bg-gray-500/20"
154+ >
155+ < div className = "font-medium" > { item . name } </ div >
156+ { item . description ? (
157+ < div className = "text-sm text-gray-500 dark:text-gray-400" >
158+ { item . description }
130159 </ div >
131- ) : ! results ?. length ? (
132- < div className = "px-3 py-2" > No packages found</ div >
133160 ) : null }
134- { results ?. map ( ( item ) => (
135- < Command . Item
136- key = { item . name }
137- value = { item . name }
138- onSelect = { handleSelect }
139- className = "px-3 py-2 cursor-pointer hover:bg-gray-500/20 data-[selected=true]:bg-gray-500/20"
140- >
141- < div className = "font-medium" > { item . label || item . name } </ div >
142- < div className = "text-sm text-gray-500 dark:text-gray-400" >
143- { item . description }
144- </ div >
145- < div className = "text-xs text-gray-400 dark:text-gray-500" >
146- { item . version ? `v${ item . version } • ` : '' }
147- { item . publisher ?. username }
148- </ div >
149- </ Command . Item >
150- ) ) }
151- </ Command . List >
152- ) : null }
153- </ Command >
154- </ div >
161+ < div className = "text-xs text-gray-400 dark:text-gray-500" >
162+ { item . version ? `v${ item . version } ` : '' }
163+ { item . version && item . publisher ?. username ? ' • ' : '' }
164+ { item . publisher ?. username }
165+ </ div >
166+ </ Command . Item >
167+ ) ) }
168+ </ Command . List >
169+ </ Command >
155170 </ div >
156171 )
157172}
0 commit comments