178 lines
5.3 KiB
TypeScript
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'
|