Spaces:
Running
Running
| 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> | |
| ) | |
| } | |