mesa-react / frontend /src /components /SinglePillAutocomplete.jsx
Guilherme Silberfarb Costa
melhorias esteticas e funcionais
303655d
import React, { useEffect, useMemo, useRef, useState } from 'react'
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 }
}
export default function SinglePillAutocomplete({
value,
onChange,
options = [],
placeholder = 'Selecione um item',
panelTitle = '',
emptyMessage = 'Nenhuma sugestao encontrada.',
loading = false,
disabled = false,
onOpenChange = null,
}) {
const rootRef = useRef(null)
const inputRef = useRef(null)
const [query, setQuery] = useState('')
const [open, setOpen] = useState(false)
const [activeIndex, setActiveIndex] = useState(-1)
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
.filter((item) => (
normalizeSearchText(item.label).includes(queryNormalized)
|| normalizeSearchText(item.secondary).includes(queryNormalized)
))
.slice(0, 160)
}, [loading, normalizedOptions, queryNormalized])
useEffect(() => {
if (!open) return undefined
function onDocumentMouseDown(event) {
if (!rootRef.current) return
if (!rootRef.current.contains(event.target)) 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 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"
value={query}
onChange={onInputChange}
onFocus={onInputFocus}
onKeyDown={onInputKeyDown}
placeholder={selectedOption ? '' : placeholder}
autoComplete="off"
autoCorrect="off"
autoCapitalize="none"
spellCheck={false}
disabled={disabled}
/>
</div>
{open && !disabled ? (
<div className="chip-autocomplete-panel" 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>
) : null}
</div>
)
}