Skip to content

Commit 8445536

Browse files
committed
fix(npm-stats): use official npm registry search and fix search UX
- Swap deprecated api.npms.io for registry.npmjs.org so results reflect current package versions - Always mount Command.List and stabilize the synthetic "Use X" item to stop the input losing focus while typing - Replace custom overlay for the combine-packages modal with @radix-ui/react-dialog to match the rest of the site's modals
1 parent 21bff8e commit 8445536

2 files changed

Lines changed: 124 additions & 99 deletions

File tree

src/components/npm-stats/PackageSearch.tsx

Lines changed: 94 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,18 @@ import { useDebouncedValue } from '@tanstack/react-pacer'
33
import { Search } from 'lucide-react'
44
import { keepPreviousData, useQuery } from '@tanstack/react-query'
55
import { Command } from 'cmdk'
6+
import { twMerge } from 'tailwind-merge'
67
import { Spinner } from '~/components/Spinner'
78

89
type 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+
1618
export 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
}

src/routes/stats/npm/index.tsx

Lines changed: 30 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as React from 'react'
22
import { Link, createFileRoute } from '@tanstack/react-router'
33
import * as v from 'valibot'
44
import { useThrottledCallback } from '@tanstack/react-pacer'
5+
import * as DialogPrimitive from '@radix-ui/react-dialog'
56
import { X } from 'lucide-react'
67
import { useQuery } from '@tanstack/react-query'
78
import { Card } from '~/components/Card'
@@ -601,29 +602,38 @@ function RouteComponent() {
601602
/>
602603

603604
{/* Combine Package Dialog */}
604-
{combiningPackage && (
605-
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-2 sm:p-4">
606-
<div className="bg-white dark:bg-gray-800 rounded-lg p-2 sm:p-4 w-full max-w-md">
607-
<div className="flex justify-between items-center mb-2 sm:mb-4">
608-
<h3 className="text-base sm:text-lg font-medium">
605+
<DialogPrimitive.Root
606+
open={combiningPackage !== null}
607+
onOpenChange={(open) => {
608+
if (!open) setCombiningPackage(null)
609+
}}
610+
>
611+
<DialogPrimitive.Portal>
612+
<DialogPrimitive.Overlay className="fixed inset-0 z-[999] bg-black/60 backdrop-blur-sm" />
613+
<DialogPrimitive.Content className="fixed left-1/2 top-1/2 z-[1000] w-[calc(100%-1rem)] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-xl bg-white dark:bg-gray-900 p-4 shadow-xl outline-none">
614+
<div className="flex justify-between items-center mb-4">
615+
<DialogPrimitive.Title className="text-base sm:text-lg font-medium text-gray-900 dark:text-gray-100">
609616
Add packages to {combiningPackage}
610-
</h3>
611-
<button
612-
onClick={() => setCombiningPackage(null)}
613-
className="p-0.5 sm:p-1 hover:text-red-500"
614-
>
617+
</DialogPrimitive.Title>
618+
<DialogPrimitive.Close className="rounded-full p-1 hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500">
615619
<X className="w-4 h-4 sm:w-5 sm:h-5" />
616-
</button>
620+
</DialogPrimitive.Close>
617621
</div>
618-
<PackageSearch
619-
onSelect={handleAddToGroup}
620-
placeholder="Search for packages to add..."
621-
// eslint-disable-next-line jsx-a11y/no-autofocus
622-
autoFocus={true}
623-
/>
624-
</div>
625-
</div>
626-
)}
622+
<DialogPrimitive.Description className="sr-only">
623+
Search for additional npm packages to combine with{' '}
624+
{combiningPackage}.
625+
</DialogPrimitive.Description>
626+
{combiningPackage && (
627+
<PackageSearch
628+
onSelect={handleAddToGroup}
629+
placeholder="Search for packages to add..."
630+
// eslint-disable-next-line jsx-a11y/no-autofocus
631+
autoFocus={true}
632+
/>
633+
)}
634+
</DialogPrimitive.Content>
635+
</DialogPrimitive.Portal>
636+
</DialogPrimitive.Root>
627637

628638
{/* Color Picker Popover */}
629639
{colorPickerPackage && colorPickerPosition && (

0 commit comments

Comments
 (0)