Add country suggestions to sidebar search autocomplete

Typing a country name in the search bar now shows matching countries
with their flag emoji, navigating to /i/iso3166:<CODE> on click.
Works on both desktop (ProfileSearchDropdown) and mobile
(MobileSearchSheet) with full keyboard navigation support.
This commit is contained in:
Alex Gleason
2026-03-01 22:31:24 -06:00
parent e5e7dd6564
commit 8280f0ee2d
3 changed files with 179 additions and 32 deletions
+66 -11
View File
@@ -1,4 +1,4 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { Search, UserRoundCheck, X } from 'lucide-react';
import { nip19 } from 'nostr-tools';
@@ -9,6 +9,7 @@ import { genUserName } from '@/lib/genUserName';
import { useNip05Verify } from '@/hooks/useNip05Verify';
import { getNostrIdentifierPath } from '@/lib/nostrIdentifier';
import { getProfileUrl } from '@/lib/profileUrl';
import { searchCountries, type CountryEntry } from '@/lib/countries';
import { useQueryClient } from '@tanstack/react-query';
import { cn } from '@/lib/utils';
@@ -26,6 +27,12 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) {
const { data: profiles, isFetching, followedPubkeys } = useSearchProfiles(query);
// Country suggestions (local, synchronous)
const countries = useMemo(() => searchCountries(query, 3), [query]);
// Total selectable items: countries + profiles
const totalItems = countries.length + (profiles?.length ?? 0);
// Focus input when opened
useEffect(() => {
if (open) {
@@ -48,6 +55,11 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) {
onClose();
}, [onClose]);
const handleSelectCountry = useCallback((country: CountryEntry) => {
handleClose();
navigate(`/i/iso3166:${country.code}`);
}, [navigate, handleClose]);
const handleSelect = useCallback((profile: SearchProfile) => {
const nip05 = profile.metadata.nip05;
const nip05Verified = !!nip05 && queryClient.getQueryData<boolean>(['nip05-verify', nip05, profile.pubkey]) === true;
@@ -78,29 +90,31 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) {
}
if (e.key === 'Enter') {
e.preventDefault();
if (profiles && selectedIndex >= 0 && selectedIndex < profiles.length) {
handleSelect(profiles[selectedIndex]);
if (selectedIndex >= 0 && selectedIndex < totalItems) {
if (selectedIndex < countries.length) {
handleSelectCountry(countries[selectedIndex]);
} else {
handleSelect(profiles![selectedIndex - countries.length]);
}
} else {
handleTextSearch();
}
return;
}
if (!profiles || profiles.length === 0) return;
if (totalItems === 0) return;
if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : profiles.length - 1));
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : totalItems - 1));
} else if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex((prev) => (prev < profiles.length - 1 ? prev + 1 : 0));
setSelectedIndex((prev) => (prev < totalItems - 1 ? prev + 1 : 0));
}
};
const hasResults = query.trim().length > 0 && (profiles && profiles.length > 0);
const hasResults = query.trim().length > 0 && (countries.length > 0 || (profiles && profiles.length > 0));
if (!open) return null;
const resultItems = hasResults ? [...profiles!] : [];
return (
<>
{/* Backdrop — doesn't cover the bottom nav (z-30) */}
@@ -116,11 +130,19 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) {
{/* Results list — reversed so closest to input = most relevant */}
{hasResults && (
<div className="flex flex-col-reverse bg-popover/95 rounded-2xl mx-6 mb-0.5 overflow-hidden max-h-[55vh] overflow-y-auto shadow-lg">
{resultItems.map((profile, index) => (
{countries.map((country, index) => (
<SearchCountryItem
key={country.code}
country={country}
isSelected={index === selectedIndex}
onClick={handleSelectCountry}
/>
))}
{profiles && profiles.map((profile, index) => (
<SearchProfileItem
key={profile.pubkey}
profile={profile}
isSelected={index === selectedIndex}
isSelected={index + countries.length === selectedIndex}
isFollowed={followedPubkeys.has(profile.pubkey)}
onClick={handleSelect}
/>
@@ -172,6 +194,39 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) {
);
}
function SearchCountryItem({
country,
isSelected,
onClick,
}: {
country: CountryEntry;
isSelected: boolean;
onClick: (country: CountryEntry) => void;
}) {
return (
<button
role="option"
aria-selected={isSelected}
className={cn(
'w-full flex items-center gap-3 px-4 py-3 text-left transition-colors',
isSelected ? 'bg-accent text-accent-foreground' : 'hover:bg-secondary/60',
)}
onClick={() => onClick(country)}
onMouseDown={(e) => e.preventDefault()}
>
<div className="size-9 shrink-0 rounded-full bg-secondary flex items-center justify-center">
<span className="text-lg leading-none" role="img" aria-label={`Flag of ${country.name}`}>
{country.flag}
</span>
</div>
<div className="flex-1 min-w-0">
<span className="font-semibold text-sm truncate">{country.name}</span>
<div className="text-xs text-muted-foreground">{country.code}</div>
</div>
</button>
);
}
function SearchProfileItem({
profile,
isSelected,
+92 -21
View File
@@ -1,4 +1,4 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { Search, UserRoundCheck } from 'lucide-react';
import { nip19 } from 'nostr-tools';
@@ -11,6 +11,7 @@ import { useNip05Verify } from '@/hooks/useNip05Verify';
import { getNostrIdentifierPath } from '@/lib/nostrIdentifier';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { getProfileUrl } from '@/lib/profileUrl';
import { searchCountries, type CountryEntry } from '@/lib/countries';
import { useQueryClient } from '@tanstack/react-query';
import { cn } from '@/lib/utils';
@@ -43,14 +44,17 @@ export function ProfileSearchDropdown({
const { data: profiles, isFetching, followedPubkeys } = useSearchProfiles(query);
// Country suggestions (local, synchronous)
const countries = useMemo(() => searchCountries(query, 3), [query]);
// Show dropdown when we have results, or when text search is enabled and there's a query
useEffect(() => {
if (query.trim().length > 0) {
if (enableTextSearch || (profiles && profiles.length > 0)) {
if (enableTextSearch || (profiles && profiles.length > 0) || countries.length > 0) {
setOpen(true);
}
}
}, [profiles, query, enableTextSearch]);
}, [profiles, query, enableTextSearch, countries.length]);
// Reset selected index when results change
useEffect(() => {
@@ -95,6 +99,15 @@ export function ProfileSearchDropdown({
navigate(`/search?q=${encodeURIComponent(query.trim())}`);
}, [enableTextSearch, query, navigate]);
// Total selectable items: countries + profiles
const totalItems = countries.length + (profiles?.length ?? 0);
const handleSelectCountry = useCallback((country: CountryEntry) => {
setOpen(false);
setQuery('');
navigate(`/i/iso3166:${country.code}`);
}, [navigate]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
@@ -105,12 +118,18 @@ export function ProfileSearchDropdown({
if (e.key === 'Enter') {
e.preventDefault();
// If a profile is highlighted, select it
if (open && profiles && selectedIndex >= 0 && selectedIndex < profiles.length) {
const profile = profiles[selectedIndex];
const nip05 = profile.metadata.nip05;
const nip05Verified = !!nip05 && queryClient.getQueryData<boolean>(['nip05-verify', nip05, profile.pubkey]) === true;
handleSelect(profile, getProfileUrl(profile.pubkey, profile.metadata, nip05Verified));
if (open && selectedIndex >= 0 && selectedIndex < totalItems) {
if (selectedIndex < countries.length) {
// Country item
handleSelectCountry(countries[selectedIndex]);
} else {
// Profile item
const profileIndex = selectedIndex - countries.length;
const profile = profiles![profileIndex];
const nip05 = profile.metadata.nip05;
const nip05Verified = !!nip05 && queryClient.getQueryData<boolean>(['nip05-verify', nip05, profile.pubkey]) === true;
handleSelect(profile, getProfileUrl(profile.pubkey, profile.metadata, nip05Verified));
}
} else {
// Otherwise do a text search
handleTextSearch();
@@ -118,16 +137,16 @@ export function ProfileSearchDropdown({
return;
}
if (!open || !profiles || profiles.length === 0) return;
if (!open || totalItems === 0) return;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setSelectedIndex((prev) => (prev < profiles.length - 1 ? prev + 1 : 0));
setSelectedIndex((prev) => (prev < totalItems - 1 ? prev + 1 : 0));
break;
case 'ArrowUp':
e.preventDefault();
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : profiles.length - 1));
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : totalItems - 1));
break;
}
};
@@ -135,7 +154,7 @@ export function ProfileSearchDropdown({
// Scroll selected item into view
useEffect(() => {
if (selectedIndex >= 0 && listRef.current) {
const items = listRef.current.querySelectorAll('[data-profile-item]');
const items = listRef.current.querySelectorAll('[data-search-item]');
items[selectedIndex]?.scrollIntoView({ block: 'nearest' });
}
}, [selectedIndex]);
@@ -187,18 +206,26 @@ export function ProfileSearchDropdown({
</div>
{/* Dropdown results — only when text search is not enabled */}
{!enableTextSearch && open && profiles && profiles.length > 0 && (
{!enableTextSearch && open && (countries.length > 0 || (profiles && profiles.length > 0)) && (
<div
ref={listRef}
role="listbox"
className="absolute top-full left-0 right-0 mt-1.5 z-50 rounded-xl border border-border bg-popover shadow-lg overflow-hidden animate-in fade-in-0 zoom-in-95 slide-in-from-top-2 duration-150"
>
<div className="max-h-[320px] overflow-y-auto py-1">
{profiles.map((profile, index) => (
{countries.map((country, index) => (
<CountryItem
key={country.code}
country={country}
isSelected={index === selectedIndex}
onClick={handleSelectCountry}
/>
))}
{profiles && profiles.map((profile, index) => (
<ProfileItem
key={profile.pubkey}
profile={profile}
isSelected={index === selectedIndex}
isSelected={index + countries.length === selectedIndex}
isFollowed={followedPubkeys.has(profile.pubkey)}
onClick={handleSelect}
/>
@@ -210,12 +237,12 @@ export function ProfileSearchDropdown({
{/* Text search option */}
{enableTextSearch && open && query.trim().length > 0 && (
<div className="absolute top-full left-0 right-0 mt-1.5 z-50 rounded-xl border border-border bg-popover shadow-lg overflow-hidden animate-in fade-in-0 zoom-in-95 slide-in-from-top-2 duration-150">
<div className="max-h-[320px] overflow-y-auto py-1">
<div ref={listRef} className="max-h-[320px] overflow-y-auto py-1">
{/* Search text option */}
<button
className={cn(
'w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors cursor-pointer',
(!profiles || profiles.length === 0 || selectedIndex === -1) ? 'bg-accent text-accent-foreground' : 'hover:bg-secondary/60',
(totalItems === 0 || selectedIndex === -1) ? 'bg-accent text-accent-foreground' : 'hover:bg-secondary/60',
)}
onClick={handleTextSearch}
onMouseDown={(e) => e.preventDefault()}
@@ -228,12 +255,22 @@ export function ProfileSearchDropdown({
</span>
</button>
{/* Country results */}
{countries.map((country, index) => (
<CountryItem
key={country.code}
country={country}
isSelected={index === selectedIndex}
onClick={handleSelectCountry}
/>
))}
{/* Profile results */}
{profiles && profiles.length > 0 && profiles.map((profile, index) => (
<ProfileItem
key={profile.pubkey}
profile={profile}
isSelected={index === selectedIndex}
isSelected={index + countries.length === selectedIndex}
isFollowed={followedPubkeys.has(profile.pubkey)}
onClick={handleSelect}
/>
@@ -243,7 +280,7 @@ export function ProfileSearchDropdown({
)}
{/* Empty state — only when text search is not enabled */}
{!enableTextSearch && open && query.trim().length > 0 && !isFetching && profiles && profiles.length === 0 && (
{!enableTextSearch && open && query.trim().length > 0 && !isFetching && countries.length === 0 && profiles && profiles.length === 0 && (
<div className="absolute top-full left-0 right-0 mt-1.5 z-50 rounded-xl border border-border bg-popover shadow-lg overflow-hidden animate-in fade-in-0 zoom-in-95 slide-in-from-top-2 duration-150">
<div className="py-6 text-center text-sm text-muted-foreground">
No profiles found
@@ -254,6 +291,40 @@ export function ProfileSearchDropdown({
);
}
function CountryItem({
country,
isSelected,
onClick,
}: {
country: CountryEntry;
isSelected: boolean;
onClick: (country: CountryEntry) => void;
}) {
return (
<button
data-search-item
role="option"
aria-selected={isSelected}
className={cn(
'w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors cursor-pointer',
isSelected ? 'bg-accent text-accent-foreground' : 'hover:bg-secondary/60',
)}
onClick={() => onClick(country)}
onMouseDown={(e) => e.preventDefault()}
>
<div className="size-10 shrink-0 rounded-full bg-secondary flex items-center justify-center">
<span className="text-lg leading-none" role="img" aria-label={`Flag of ${country.name}`}>
{country.flag}
</span>
</div>
<div className="flex-1 min-w-0">
<span className="font-semibold text-sm truncate">{country.name}</span>
<div className="text-xs text-muted-foreground">{country.code}</div>
</div>
</button>
);
}
function ProfileItem({
profile,
isSelected,
@@ -279,7 +350,7 @@ function ProfileItem({
return (
<button
data-profile-item
data-search-item
role="option"
aria-selected={isSelected}
className={cn(
+21
View File
@@ -197,6 +197,27 @@ export const COUNTRIES: Record<string, { name: string; flag: string }> = {
ZW: { name: 'Zimbabwe', flag: '🇿🇼' },
};
/** Pre-sorted array of country entries for searching. */
const COUNTRY_LIST = Object.entries(COUNTRIES)
.map(([code, { name, flag }]) => ({ code, name, flag }))
.sort((a, b) => a.name.localeCompare(b.name));
export type CountryEntry = typeof COUNTRY_LIST[number];
/** Search countries by name or code. Returns up to `limit` matches (default 5). */
export function searchCountries(query: string, limit = 5): CountryEntry[] {
const q = query.trim().toLowerCase();
if (!q) return [];
const results: CountryEntry[] = [];
for (const entry of COUNTRY_LIST) {
if (entry.name.toLowerCase().includes(q) || entry.code.toLowerCase() === q) {
results.push(entry);
if (results.length >= limit) break;
}
}
return results;
}
/** Get country info from an ISO 3166 code (country or subdivision). */
export function getCountryInfo(code: string): { name: string; flag: string; subdivision?: string } | null {
const upper = code.toUpperCase();