mesa-react / frontend /src /components /SinglePillAutocomplete.jsx
Guilherme Silberfarb Costa
inclusao de mais avaliandos na pesquisa
c485ae0
Raw
History Blame Contribute Delete
11.5 kB
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 (
<div className={`chip-autocomplete chip-autocomplete-single${open ? ' is-open' : ''}${disabled ? ' is-disabled' : ''}`} ref={rootRef}>
<div className={`chip-autocomplete-single-control${disabled ? ' is-disabled' : ''}`}>
{selectedOption ? (
<span className="chip-autocomplete-selected chip-autocomplete-selected-inline">
<span>{selectedOption.label}</span>
{!disabled ? (
<button
type="button"
className="chip-autocomplete-selected-remove"
onMouseDown={clearSelection}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
}}
aria-label={`Remover ${selectedOption.label}`}
>
×
</button>
) : null}
</span>
) : null}
<input
ref={inputRef}
type="text"
className="chip-autocomplete-single-input"
name={resolvedInputName}
value={query}
onChange={onInputChange}
onFocus={onInputFocus}
onKeyDown={onInputKeyDown}
placeholder={selectedOption ? '' : placeholder}
autoComplete={inputAutoComplete}
autoCorrect="off"
autoCapitalize="none"
spellCheck={false}
disabled={disabled}
data-lpignore="true"
data-1p-ignore="true"
/>
</div>
{open && !disabled && typeof document !== 'undefined'
? createPortal(
<div
ref={panelRef}
className="chip-autocomplete-panel is-floating"
style={{ ...panelStyle, visibility: panelStyle.left ? 'visible' : 'hidden' }}
role="listbox"
>
{panelTitle ? <div className="chip-autocomplete-panel-head">{panelTitle}</div> : null}
{loading ? (
<div className="chip-autocomplete-empty">Carregando lista...</div>
) : filteredOptions.length ? (
<div className="chip-autocomplete-chip-wrap">
{filteredOptions.map((item, idx) => (
<button
type="button"
key={`single-chip-${item.value}-${idx}`}
className={`chip-autocomplete-chip${idx === activeIndex ? ' is-active' : ''}`}
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
selectOption(item)
}}
title={item.secondary ? `${item.label} | ${item.secondary}` : item.label}
>
{item.label}
</button>
))}
</div>
) : (
<div className="chip-autocomplete-empty">
{emptyMessage}
</div>
)}
</div>,
document.body,
)
: null}
</div>
)
}