Skip to content

Commit ea7aa23

Browse files
committed
refactor: search modal
1 parent 4b16d3f commit ea7aa23

File tree

3 files changed

+150
-152
lines changed

3 files changed

+150
-152
lines changed

app/globals.css

Lines changed: 51 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -522,56 +522,70 @@
522522
}
523523

524524
/* SearchModal Component Utilities */
525-
.search-button {
526-
@apply px-6 hover:!bg-transparent font-medium transition-all h-full cursor-pointer text-base text-purple-100 flex items-center gap-2;
527-
}
525+
#search-modal {
526+
.trigger {
527+
@apply px-6 hover:!bg-transparent font-medium transition-all h-full cursor-pointer text-base text-purple-100 flex items-center gap-2;
528+
}
528529

529-
.search-kbd {
530-
@apply pointer-events-none hidden sm:inline-flex h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium opacity-100;
530+
.kbd {
531+
@apply pointer-events-none hidden sm:inline-flex h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium opacity-100;
532+
}
531533
}
532534

533-
.search-dialog {
534-
@apply !bg-dark-400 max-w-sm sm:max-w-md md:max-w-2xl mx-auto;
535-
}
535+
.dialog {
536+
@apply bg-dark-400! max-w-sm sm:max-w-md md:max-w-2xl mx-auto;
536537

537-
.search-list {
538-
@apply bg-dark-500 max-h-[400px];
539-
}
538+
.cmd-input {
539+
@apply bg-dark-500!;
540540

541-
.search-empty {
542-
@apply py-6 text-center text-sm text-gray-400;
543-
}
541+
input {
542+
@apply placeholder:text-purple-100!;
543+
}
544+
}
544545

545-
.search-heading {
546-
@apply flex items-center gap-2 text-purple-100;
547-
}
546+
.list {
547+
@apply bg-dark-500 max-h-[400px];
548+
}
548549

549-
.search-group {
550-
@apply bg-dark-500 text-purple-100;
551-
}
550+
.empty {
551+
@apply py-6 text-center text-sm text-gray-400;
552+
}
552553

553-
.search-item {
554-
@apply grid grid-cols-4 gap-4 items-center data-[selected=true]:bg-dark-400 transition-all cursor-pointer hover:!bg-dark-400/50 py-3;
555-
}
554+
.heading {
555+
@apply flex items-center gap-2 text-purple-100;
556+
}
556557

557-
.search-coin-info {
558-
@apply flex gap-2 items-center col-span-2;
559-
}
558+
.group {
559+
@apply bg-dark-500 text-purple-100;
560+
}
560561

561-
.search-coin-image {
562-
@apply size-9 rounded-full;
563-
}
562+
.search-item {
563+
@apply grid! grid-cols-4! gap-4! items-center! data-[selected=true]:bg-dark-400! transition-all! cursor-pointer! hover:bg-dark-400/50! py-3!;
564564

565-
.search-coin-symbol {
566-
@apply text-sm text-purple-100 uppercase;
567-
}
565+
.coin-info {
566+
@apply flex! gap-2! items-center! col-span-2!;
568567

569-
.search-coin-price {
570-
@apply font-semibold text-sm lg:text-base;
571-
}
568+
img {
569+
@apply size-9 rounded-full;
570+
}
571+
572+
div {
573+
@apply flex flex-col;
574+
}
572575

573-
.search-coin-change {
574-
@apply flex gap-1 text-sm lg:text-base items-center font-medium;
576+
.coin-symbol {
577+
@apply text-sm text-purple-100 uppercase;
578+
}
579+
}
580+
581+
.coin-price {
582+
@apply font-semibold text-sm lg:text-base;
583+
}
584+
585+
.coin-change {
586+
@apply flex gap-1 text-sm lg:text-base items-center font-medium;
587+
}
588+
}
575589
}
576590

577591
/* Converter Component Utilities */

components/SearchModal.tsx

Lines changed: 91 additions & 115 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,58 @@ import { cn, formatPercentage, formatPrice } from '@/lib/utils';
1818
import useSWR from 'swr';
1919
import { useDebounce, useKey } from 'react-use';
2020

21+
const TRENDING_LIMIT = 8;
22+
const SEARCH_LIMIT = 10;
23+
24+
const SearchItem = ({ coin, onSelect, isActiveName }: SearchItemProps) => {
25+
const isSearchCoin =
26+
typeof coin.data?.price_change_percentage_24h === 'number';
27+
28+
const change = isSearchCoin
29+
? (coin as SearchCoin).data?.price_change_percentage_24h ?? 0
30+
: (coin as TrendingCoin['item']).data.price_change_percentage_24h?.usd ?? 0;
31+
32+
const price = isSearchCoin ? coin.data?.price : coin.data.price;
33+
34+
return (
35+
<CommandItem
36+
value={coin.id}
37+
onSelect={() => onSelect(coin.id)}
38+
className='search-item'
39+
>
40+
<div className='coin-info'>
41+
<Image src={coin.thumb} alt={coin.name} width={32} height={32} />
42+
43+
<div>
44+
<p className={cn('font-bold', isActiveName && 'text-white')}>
45+
{coin.name}
46+
</p>
47+
<p className='coin-symbol'>{coin.symbol}</p>
48+
</div>
49+
</div>
50+
51+
{!price && <span className='coin-price'>{formatPrice(price)}</span>}
52+
53+
<p
54+
className={cn('coin-change', {
55+
'text-green-500': change > 0,
56+
'text-red-500': change < 0,
57+
})}
58+
>
59+
{formatPercentage(change)}
60+
</p>
61+
</CommandItem>
62+
);
63+
};
64+
2165
export const SearchModal = ({
2266
initialTrendingCoins = [],
2367
}: {
2468
initialTrendingCoins: TrendingCoin[];
2569
}) => {
2670
const router = useRouter();
2771
const [open, setOpen] = useState(false);
72+
2873
const [searchQuery, setSearchQuery] = useState('');
2974
const [debouncedQuery, setDebouncedQuery] = useState('');
3075

@@ -36,10 +81,9 @@ export const SearchModal = ({
3681
[searchQuery]
3782
);
3883

39-
const {
40-
data: searchResults = [],
41-
isValidating: isSearching,
42-
} = useSWR<SearchCoin[]>(
84+
const { data: searchResults = [], isValidating: isSearching } = useSWR<
85+
SearchCoin[]
86+
>(
4387
debouncedQuery ? ['coin-search', debouncedQuery] : null,
4488
([, query]) => searchCoins(query as string),
4589
{
@@ -48,7 +92,8 @@ export const SearchModal = ({
4892
);
4993

5094
useKey(
51-
(event) => event.key?.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey),
95+
(event) =>
96+
event.key?.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey),
5297
(event) => {
5398
event.preventDefault();
5499
setOpen((prev) => !prev);
@@ -65,155 +110,86 @@ export const SearchModal = ({
65110
};
66111

67112
const hasQuery = debouncedQuery.length > 0;
68-
const trendingCoins = initialTrendingCoins;
113+
const trendingCoins = initialTrendingCoins.slice(0, TRENDING_LIMIT);
69114
const showTrending = !hasQuery && trendingCoins.length > 0;
70115

116+
const isSearchEmpty = !isSearching && !hasQuery && !showTrending;
117+
const isTrendingListVisible = !isSearching && showTrending;
118+
119+
const isNoResults = !isSearching && hasQuery && searchResults.length === 0;
120+
const isResultsVisible = !isSearching && hasQuery && searchResults.length > 0;
121+
71122
return (
72-
<>
73-
<Button
74-
variant='ghost'
75-
onClick={() => setOpen(true)}
76-
className='search-button'
77-
>
123+
<div id='search-modal'>
124+
<Button variant='ghost' onClick={() => setOpen(true)} className='trigger'>
78125
<SearchIcon size={18} />
79126
Search
80-
<kbd className='search-kbd'>
127+
<kbd className='kbd'>
81128
<span className='text-xs'></span>K
82129
</kbd>
83130
</Button>
84131

85-
{/* Dialog */}
86132
<CommandDialog
87133
open={open}
88134
onOpenChange={setOpen}
89-
className='search-dialog'
135+
className='dialog'
136+
data-search-modal
90137
>
91-
<div className='bg-dark-500'>
138+
<div className='cmd-input'>
92139
<CommandInput
93-
className='placeholder:text-purple-100'
94140
placeholder='Search for a token by name or symbol...'
95141
value={searchQuery}
96142
onValueChange={setSearchQuery}
97143
/>
98144
</div>
99145

100-
<CommandList className='custom-scrollbar search-list'>
101-
{isSearching && <div className='search-empty'>Searching...</div>}
146+
<CommandList className='list custom-scrollbar'>
147+
{isSearching && <div className='empty'>Searching...</div>}
102148

103-
{!isSearching && !hasQuery && !showTrending && (
104-
<div className='search-empty'>Type to search for coins...</div>
149+
{isSearchEmpty && (
150+
<div className='empty'>Type to search for coins...</div>
105151
)}
106152

107-
{!isSearching && showTrending && (
153+
{isTrendingListVisible && (
108154
<CommandGroup
109155
heading={
110-
<div className='search-heading'>
156+
<div className='heading'>
111157
<TrendingUp size={16} />
112158
Trending Coins
113159
</div>
114160
}
115-
className='bg-dark-500'
161+
className='group'
116162
>
117-
{trendingCoins.slice(0, 8).map((trendingCoin) => {
118-
const coin = trendingCoin.item;
119-
120-
return (
121-
<CommandItem
122-
key={coin.id}
123-
value={coin.id}
124-
onSelect={() => handleSelect(coin.id)}
125-
className='search-item'
126-
>
127-
<div className='search-coin-info'>
128-
<Image
129-
src={coin.thumb}
130-
alt={coin.name}
131-
width={32}
132-
height={32}
133-
className='search-coin-image'
134-
/>
135-
<div className='flex flex-col'>
136-
<p className='font-bold'>{coin.name}</p>
137-
<p className='search-coin-symbol'>{coin.symbol}</p>
138-
</div>
139-
</div>
140-
141-
<span className='search-coin-price'>
142-
{formatPrice(coin.data.price)}
143-
</span>
144-
145-
<p
146-
className={cn('search-coin-change', {
147-
'text-green-500':
148-
coin.data.price_change_percentage_24h.usd > 0,
149-
'text-red-500':
150-
coin.data.price_change_percentage_24h.usd < 0,
151-
})}
152-
>
153-
{formatPercentage(
154-
coin.data.price_change_percentage_24h.usd
155-
)}
156-
</p>
157-
</CommandItem>
158-
);
159-
})}
163+
{trendingCoins.map(({ item }) => (
164+
<SearchItem
165+
key={item.id}
166+
coin={item}
167+
onSelect={handleSelect}
168+
isActiveName={false}
169+
/>
170+
))}
160171
</CommandGroup>
161172
)}
162173

163-
{!isSearching && hasQuery && searchResults.length === 0 && (
164-
<CommandEmpty>No coins found.</CommandEmpty>
165-
)}
174+
{isNoResults && <CommandEmpty>No coins found.</CommandEmpty>}
166175

167-
{!isSearching && hasQuery && searchResults.length > 0 && (
176+
{isResultsVisible && (
168177
<CommandGroup
169-
heading={<p className='search-heading'>Search Results</p>}
170-
className='search-group'
178+
heading={<p className='heading'>Search Results</p>}
179+
className='group'
171180
>
172-
{searchResults.slice(0, 10).map((coin) => {
173-
return (
174-
<CommandItem
175-
key={coin.id}
176-
value={coin.id}
177-
onSelect={() => handleSelect(coin.id)}
178-
className='search-item'
179-
>
180-
<div className='search-coin-info'>
181-
<Image
182-
src={coin.thumb}
183-
alt={coin.name}
184-
width={32}
185-
height={32}
186-
className='search-coin-image'
187-
/>
188-
<div className='flex flex-col'>
189-
<p className='font-bold text-white'>{coin.name}</p>
190-
<p className='search-coin-symbol'>{coin.symbol}</p>
191-
</div>
192-
</div>
193-
194-
{coin.data?.price && (
195-
<span className='search-coin-price'>
196-
{formatPrice(coin.data.price)}
197-
</span>
198-
)}
199-
200-
<p
201-
className={cn('search-coin-change', {
202-
'text-green-500':
203-
coin.data?.price_change_percentage_24h > 0,
204-
'text-red-500':
205-
coin.data?.price_change_percentage_24h < 0,
206-
})}
207-
>
208-
{formatPercentage(coin.data?.price_change_percentage_24h)}
209-
</p>
210-
</CommandItem>
211-
);
212-
})}
181+
{searchResults.slice(0, SEARCH_LIMIT).map((coin) => (
182+
<SearchItem
183+
key={coin.id}
184+
coin={coin}
185+
onSelect={handleSelect}
186+
isActiveName
187+
/>
188+
))}
213189
</CommandGroup>
214190
)}
215191
</CommandList>
216192
</CommandDialog>
217-
</>
193+
</div>
218194
);
219195
};

types.d.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,3 +288,11 @@ interface Pagination {
288288
interface HeaderProps {
289289
trendingCoins: TrendingCoin[];
290290
}
291+
292+
type SearchItemCoin = SearchCoin | TrendingCoin['item'];
293+
294+
interface SearchItemProps {
295+
coin: SearchItemCoin;
296+
onSelect: (coinId: string) => void;
297+
isActiveName: boolean;
298+
}

0 commit comments

Comments
 (0)