import React, { useEffect, useId, useMemo, useRef, useState } from 'react' import { createPortal } from 'react-dom' function normalizeSearchText(value) { return String(value || '') .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .toLowerCase() .trim() } function normalizeOption(option) { if (typeof option === 'string') { const text = String(option || '').trim() return text ? { value: text, label: text, secondary: '' } : null } if (!option || typeof option !== 'object') return null const rawValue = option.value ?? option.id ?? option.key ?? '' const value = String(rawValue || '').trim() if (!value) return null const label = String(option.label ?? option.nome_modelo ?? option.arquivo ?? value).trim() || value const secondary = String(option.secondary ?? option.arquivo ?? '').trim() return { value, label, secondary } } function scoreOptionMatch(option, queryNormalized) { if (!queryNormalized) return 1 const labelNorm = normalizeSearchText(option?.label || '') const secondaryNorm = normalizeSearchText(option?.secondary || '') const labelTokens = labelNorm.split(/\s+/).filter(Boolean) const secondaryTokens = secondaryNorm.split(/\s+/).filter(Boolean) if (labelNorm === queryNormalized) return 400 if (labelTokens.includes(queryNormalized)) return 320 if (labelNorm.startsWith(queryNormalized)) return 280 if (labelTokens.some((token) => token.startsWith(queryNormalized))) return 220 if (labelNorm.includes(queryNormalized)) return 180 if (secondaryNorm === queryNormalized) return 160 if (secondaryTokens.includes(queryNormalized)) return 140 if (secondaryNorm.includes(queryNormalized)) return 120 return 0 } export default function SinglePillAutocomplete({ value, onChange, options = [], placeholder = 'Selecione um item', panelTitle = '', emptyMessage = 'Nenhuma sugestao encontrada.', loading = false, disabled = false, onOpenChange = null, inputName = '', inputAutoComplete = 'new-password', }) { const rootRef = useRef(null) const inputRef = useRef(null) const panelRef = useRef(null) const [query, setQuery] = useState('') const [open, setOpen] = useState(false) const [activeIndex, setActiveIndex] = useState(-1) const [panelStyle, setPanelStyle] = useState({}) const generatedInputId = useId() const resolvedInputName = inputName || `mesa_single_${String(generatedInputId).replace(/[^a-zA-Z0-9_-]/g, '')}` const selectedValue = String(value || '') const normalizedOptions = useMemo(() => { const unique = [] const seen = new Set() ;(options || []).forEach((item) => { const normalized = normalizeOption(item) if (!normalized) return if (seen.has(normalized.value)) return seen.add(normalized.value) unique.push(normalized) }) return unique }, [options]) const selectedOption = useMemo( () => normalizedOptions.find((item) => item.value === selectedValue) || null, [normalizedOptions, selectedValue], ) const queryNormalized = normalizeSearchText(query) const filteredOptions = useMemo(() => { if (loading) return [] if (!queryNormalized) return normalizedOptions.slice(0, 160) return normalizedOptions .map((item, index) => ({ item, index, score: scoreOptionMatch(item, queryNormalized) })) .filter((entry) => entry.score > 0) .sort((a, b) => { if (b.score !== a.score) return b.score - a.score return a.index - b.index }) .map((entry) => entry.item) .slice(0, 160) }, [loading, normalizedOptions, queryNormalized]) useEffect(() => { if (!open) return undefined function onDocumentMouseDown(event) { const target = event.target if (rootRef.current?.contains(target) || panelRef.current?.contains(target)) return setOpen(false) } document.addEventListener('mousedown', onDocumentMouseDown) return () => document.removeEventListener('mousedown', onDocumentMouseDown) }, [open]) useEffect(() => { if (typeof onOpenChange !== 'function') return onOpenChange(Boolean(open && !disabled)) }, [open, disabled, onOpenChange]) useEffect(() => () => { if (typeof onOpenChange === 'function') onOpenChange(false) }, [onOpenChange]) useEffect(() => { if (!open || filteredOptions.length === 0) { setActiveIndex(-1) return } if (activeIndex >= filteredOptions.length) setActiveIndex(filteredOptions.length - 1) }, [activeIndex, filteredOptions, open]) function updatePanelPosition() { if (!open || !rootRef.current || !panelRef.current || typeof window === 'undefined') return const bounds = rootRef.current.getBoundingClientRect() const panel = panelRef.current const viewportWidth = window.innerWidth || document.documentElement.clientWidth || 0 const viewportHeight = window.innerHeight || document.documentElement.clientHeight || 0 const padding = 12 const gap = 6 function clamp(value, min, max) { if (max < min) return min return Math.min(Math.max(value, min), max) } const width = clamp(bounds.width, 220, Math.max(220, viewportWidth - (padding * 2))) const availableBelow = Math.max(120, viewportHeight - bounds.bottom - gap - padding) const availableAbove = Math.max(120, bounds.top - gap - padding) const shouldOpenBelow = availableBelow >= 180 || availableBelow >= availableAbove const maxHeight = shouldOpenBelow ? availableBelow : availableAbove const desiredHeight = Math.min(panel.scrollHeight || 220, maxHeight) const left = clamp(bounds.left, padding, viewportWidth - padding - width) const top = shouldOpenBelow ? clamp(bounds.bottom + gap, padding, viewportHeight - padding - desiredHeight) : clamp(bounds.top - gap - desiredHeight, padding, viewportHeight - padding - desiredHeight) setPanelStyle({ left: `${left}px`, top: `${top}px`, width: `${width}px`, maxHeight: `${Math.max(120, maxHeight)}px`, }) } useEffect(() => { if (!open) return undefined const rafId = window.requestAnimationFrame(() => { updatePanelPosition() }) function handleViewportChange() { updatePanelPosition() } window.addEventListener('resize', handleViewportChange) window.addEventListener('scroll', handleViewportChange, true) return () => { window.cancelAnimationFrame(rafId) window.removeEventListener('resize', handleViewportChange) window.removeEventListener('scroll', handleViewportChange, true) } }, [open, filteredOptions.length, loading, query, selectedValue]) function emitChange(nextValue) { if (typeof onChange === 'function') onChange(String(nextValue || '')) } function selectOption(option) { if (!option) return emitChange(option.value) setQuery('') setOpen(false) setActiveIndex(-1) } function clearSelection(event) { event.preventDefault() event.stopPropagation() emitChange('') setQuery('') setOpen(true) setActiveIndex(-1) window.requestAnimationFrame(() => inputRef.current?.focus()) } function onInputChange(event) { if (disabled) return setQuery(event.target.value) setOpen(true) setActiveIndex(-1) } function onInputFocus() { if (disabled) return setOpen(true) setActiveIndex(-1) } function onInputKeyDown(event) { if (disabled) return if (event.key === 'Escape') { setOpen(false) return } if (event.key === 'Backspace' && !query && selectedOption) { emitChange('') setOpen(true) return } if (!filteredOptions.length) return if (event.key === 'ArrowDown') { event.preventDefault() setOpen(true) setActiveIndex((prev) => (prev < 0 ? 0 : (prev + 1) % filteredOptions.length)) return } if (event.key === 'ArrowUp') { event.preventDefault() setOpen(true) setActiveIndex((prev) => { if (prev < 0) return filteredOptions.length - 1 return (prev - 1 + filteredOptions.length) % filteredOptions.length }) return } if (event.key === 'Enter') { event.preventDefault() if (activeIndex >= 0 && activeIndex < filteredOptions.length) { selectOption(filteredOptions[activeIndex]) return } if (filteredOptions.length === 1) { selectOption(filteredOptions[0]) } } } return (
{selectedOption ? ( {selectedOption.label} {!disabled ? ( ) : null} ) : null}
{open && !disabled && typeof document !== 'undefined' ? createPortal(
{panelTitle ?
{panelTitle}
: null} {loading ? (
Carregando lista...
) : filteredOptions.length ? (
{filteredOptions.map((item, idx) => ( ))}
) : (
{emptyMessage}
)}
, document.body, ) : null}
) }