components/search-mode-selector.tsx
Rama main · réplica de lectura del repositorio original.
'use client'
import { useEffect, useState, useSyncExternalStore } from 'react'
import {
IconCheck as Check,
IconChevronDown as ChevronDown
} from '@tabler/icons-react'
import { SEARCH_MODE_CONFIGS } from '@/lib/config/search-modes'
import { SearchMode } from '@/lib/types/search'
import { cn } from '@/lib/utils'
import {
getCookie,
setCookie,
subscribeToCookieChange
} from '@/lib/utils/cookies'
import { Button } from './ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from './ui/dropdown-menu'
import { HoverCard, HoverCardContent, HoverCardTrigger } from './ui/hover-card'
const VALID_SEARCH_MODES = new Set(['quick', 'adaptive'])
function getSearchModeSnapshot(): SearchMode {
const savedMode = getCookie('searchMode')
return savedMode === 'adaptive' ? 'adaptive' : 'quick'
}
interface SearchModeSelectorProps {
isAdaptiveAuthRequired?: boolean
onAdaptiveAuthRequired?: () => void
}
export function SearchModeSelector({
isAdaptiveAuthRequired = false,
onAdaptiveAuthRequired
}: SearchModeSelectorProps) {
const value = useSyncExternalStore(
subscribeToCookieChange,
getSearchModeSnapshot,
() => 'quick'
)
const [openHoverCard, setOpenHoverCard] = useState<string | null>(null)
const [justSelected, setJustSelected] = useState(false)
const [dropdownOpen, setDropdownOpen] = useState(false)
useEffect(() => {
const savedMode = getCookie('searchMode')
if (savedMode && !VALID_SEARCH_MODES.has(savedMode)) {
// Clean up invalid cookie value (e.g., old 'planning' mode)
setCookie('searchMode', 'quick')
return
}
if (isAdaptiveAuthRequired && savedMode === 'adaptive') {
setCookie('searchMode', 'quick')
}
}, [isAdaptiveAuthRequired])
const closeModeSelectControls = () => {
setOpenHoverCard(null) // Close hover card on selection
setDropdownOpen(false) // Close dropdown on selection
setJustSelected(true)
// Prevent hover card from reopening immediately
setTimeout(() => {
setJustSelected(false)
}, 500)
}
const handleModeSelect = (mode: SearchMode) => {
if (mode === 'adaptive' && isAdaptiveAuthRequired) {
setCookie('searchMode', 'quick')
closeModeSelectControls()
onAdaptiveAuthRequired?.()
return
}
setCookie('searchMode', mode)
closeModeSelectControls()
}
const selectedMode = SEARCH_MODE_CONFIGS.find(
config => config.value === value
)
const SelectedIcon = selectedMode?.icon
const selectedIndex = Math.max(
SEARCH_MODE_CONFIGS.findIndex(config => config.value === value),
0
)
const modeCount = SEARCH_MODE_CONFIGS.length
return (
<>
{/* Mobile Dropdown */}
<div className="sm:hidden">
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="gap-1 rounded-full text-xs shadow-none transition-[background-color,color,box-shadow,transform]"
>
{SelectedIcon && (
<SelectedIcon
className={cn(
'size-3.5 transition-colors',
selectedMode?.color
)}
/>
)}
<span className="text-xs font-medium">{selectedMode?.label}</span>
<ChevronDown
className={cn(
'ml-0.5 size-3 opacity-50 transition-transform duration-[160ms] ease-[var(--motion-ease-out)]',
dropdownOpen && 'rotate-180'
)}
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64" sideOffset={5}>
{SEARCH_MODE_CONFIGS.map(config => {
const ModeIcon = config.icon
const isSelected = value === config.value
return (
<DropdownMenuItem
key={config.value}
onClick={() => handleModeSelect(config.value)}
className="relative flex flex-col items-start gap-1 py-2 pl-8 pr-2 cursor-pointer focus:outline-none"
>
{isSelected && (
<Check className="absolute left-2 top-2.5 size-4" />
)}
<div className="flex items-center gap-2">
<ModeIcon
className={cn('size-4 transition-colors', config.color)}
/>
<span className="text-sm font-medium">{config.label}</span>
</div>
<div className="flex flex-col gap-0.5 ml-6">
<span className="text-xs text-muted-foreground">
{config.description}
</span>
</div>
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Desktop Toggle */}
<div className="hidden sm:block">
<div className="relative inline-flex items-center rounded-full bg-background border p-1">
{/* Animated background indicator */}
<div
className="absolute inset-1 rounded-full bg-muted transition-[transform,width] duration-[180ms] ease-[var(--motion-ease-in-out)]"
style={{
width: `calc(${100 / modeCount}% - 4px)`,
transform: `translateX(${selectedIndex * 100}%)`
}}
/>
{/* Mode buttons */}
<div className="relative flex items-center">
{SEARCH_MODE_CONFIGS.map((config, index) => {
const Icon = config.icon
const isSelected = value === config.value
return (
<HoverCard
key={config.value}
open={!justSelected && openHoverCard === config.value}
onOpenChange={open => {
if (!justSelected) {
setOpenHoverCard(open ? config.value : null)
}
}}
openDelay={100}
closeDelay={50}
>
<HoverCardTrigger asChild>
<button
type="button"
onClick={() => handleModeSelect(config.value)}
className={cn(
'relative z-10 flex-1 items-center justify-center rounded-full px-3 py-2 transition-colors duration-[140ms] ease-[var(--motion-ease-out)]',
isSelected
? 'text-foreground'
: 'text-muted-foreground hover:text-foreground/80'
)}
aria-label={`${config.label} mode`}
aria-pressed={isSelected}
>
<Icon
className={cn(
'h-3.5 w-3.5 transition-colors',
isSelected ? config.color : ''
)}
/>
</button>
</HoverCardTrigger>
<HoverCardContent
className="w-72"
align="center"
sideOffset={8}
>
<div className="space-y-2">
<div className="flex items-center gap-2">
<Icon className={cn('size-5', config.color)} />
<h4 className="text-sm font-semibold">
{config.label}
</h4>
</div>
<p className="text-xs text-muted-foreground leading-tight">
{config.description}
</p>
</div>
</HoverCardContent>
</HoverCard>
)
})}
</div>
</div>
</div>
</>
)
}