2026-01-15 11:31:09 +01:00

178 lines
5.3 KiB
TypeScript

import React, { useState, useEffect, useRef } from 'react'
import { SearchIcon } from './SearchIcon'
import { ClearButton } from './ClearButton'
import { Input } from './ui'
import { SearchSuggestions } from './SearchSuggestions'
import { saveSearchQuery } from '@/lib/searchHistory'
import { t } from '@/lib/i18n'
interface SearchBarProps {
value: string
onChange: (value: string) => void
placeholder?: string
}
function useSearchBarLocalValue(value: string): [string, (value: string) => void] {
const [localValue, setLocalValue] = useState(value)
useEffect(() => {
setLocalValue(value)
}, [value])
return [localValue, setLocalValue]
}
function useSearchBarFocusState(): [boolean, () => void, () => void] {
const [isFocused, setIsFocused] = useState(false)
const handleFocus = (): void => {
setIsFocused(true)
}
const handleBlur = (): void => {
setIsFocused(false)
}
return [isFocused, handleFocus, handleBlur]
}
interface UseSearchBarHandlersParams {
setLocalValue: (value: string) => void
onChange: (value: string) => void
isFocused: boolean
setShowSuggestions: (show: boolean) => void
}
function useSearchBarHandlers(params: UseSearchBarHandlersParams): {
handleChange: (e: React.ChangeEvent<HTMLInputElement>) => void
handleClear: () => void
handleFocusWithSuggestions: () => void
handleSelectSuggestion: (query: string) => void
handleCloseSuggestions: () => void
} {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
const newValue = e.target.value
params.setLocalValue(newValue)
params.onChange(newValue)
if (newValue.trim() && params.isFocused) {
params.setShowSuggestions(true)
}
}
const handleClear = (): void => {
params.setLocalValue('')
params.onChange('')
params.setShowSuggestions(false)
}
const handleFocusWithSuggestions = (): void => {
params.setShowSuggestions(true)
}
const handleSelectSuggestion = (query: string): void => {
params.setLocalValue(query)
params.onChange(query)
params.setShowSuggestions(false)
void saveSearchQuery(query)
}
const handleCloseSuggestions = (): void => {
params.setShowSuggestions(false)
}
return {
handleChange,
handleClear,
handleFocusWithSuggestions,
handleSelectSuggestion,
handleCloseSuggestions,
}
}
function useSearchBarState(value: string, onChange: (value: string) => void): {
localValue: string
showSuggestions: boolean
handleChange: (e: React.ChangeEvent<HTMLInputElement>) => void
handleClear: () => void
handleFocus: () => void
handleBlur: () => void
handleSelectSuggestion: (query: string) => void
handleCloseSuggestions: () => void
setShowSuggestions: (show: boolean) => void
} {
const [localValue, setLocalValue] = useSearchBarLocalValue(value)
const [showSuggestions, setShowSuggestions] = useState(false)
const [isFocused, , handleBlur] = useSearchBarFocusState()
const handlers = useSearchBarHandlers({ setLocalValue, onChange, isFocused, setShowSuggestions })
return {
localValue,
showSuggestions,
handleChange: handlers.handleChange,
handleClear: handlers.handleClear,
handleFocus: handlers.handleFocusWithSuggestions,
handleBlur,
handleSelectSuggestion: handlers.handleSelectSuggestion,
handleCloseSuggestions: handlers.handleCloseSuggestions,
setShowSuggestions,
}
}
function useClickOutsideHandler(containerRef: React.RefObject<HTMLDivElement | null>, showSuggestions: boolean, setShowSuggestions: (show: boolean) => void): void {
useEffect(() => {
if (!showSuggestions) {
return
}
const handleClickOutside = (e: MouseEvent): void => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setShowSuggestions(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [showSuggestions, setShowSuggestions, containerRef])
}
export const SearchBar = React.forwardRef<HTMLInputElement, SearchBarProps>(
({ value, onChange, placeholder }, ref): React.ReactElement => {
const defaultPlaceholder = placeholder ?? t('search.placeholder')
const containerRef = useRef<HTMLDivElement>(null)
const {
localValue,
showSuggestions,
handleChange,
handleClear,
handleFocus,
handleBlur,
handleSelectSuggestion,
handleCloseSuggestions,
setShowSuggestions,
} = useSearchBarState(value, onChange)
useClickOutsideHandler(containerRef, showSuggestions, setShowSuggestions)
return (
<div ref={containerRef} className="relative w-full">
<Input
ref={ref}
type="text"
value={localValue}
onChange={handleChange}
onFocus={handleFocus}
onBlur={handleBlur}
placeholder={defaultPlaceholder}
leftIcon={<SearchIcon />}
rightIcon={localValue ? <ClearButton onClick={handleClear} /> : undefined}
className="pr-10"
role="search"
aria-label={t('search.placeholder')}
aria-autocomplete="list"
aria-expanded={showSuggestions}
/>
{showSuggestions && (
<SearchSuggestions query={localValue} onSelect={handleSelectSuggestion} onClose={handleCloseSuggestions} />
)}
</div>
)
}
)
SearchBar.displayName = 'SearchBar'