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