import { Children, Fragment, isValidElement, useEffect, useId, useMemo, useRef, useState, } from 'react'; import type { ReactElement, ReactNode, SelectHTMLAttributes, } from 'react'; import { createPortal } from 'react-dom'; import { Icon } from './Icon'; interface FlatOption { value: string; label: string; disabled: boolean; } type SearchableSelectProps = SelectHTMLAttributes & { searchPlaceholder?: string; }; function textContent(node: ReactNode): string { if (node == null || typeof node === 'boolean') return ''; if (typeof node === 'string' || typeof node === 'number') return String(node); if (Array.isArray(node)) return node.map(textContent).join(''); if (isValidElement(node)) { return textContent((node.props as { children?: ReactNode }).children); } return ''; } function flattenOptions(node: ReactNode, target: FlatOption[]): void { Children.forEach(node, (child) => { if (!isValidElement(child)) return; const element = child as ReactElement<{ children?: ReactNode; value?: string | number; disabled?: boolean; }>; if (element.type === 'option') { target.push({ value: element.props.value == null ? textContent(element.props.children) : String(element.props.value), label: textContent(element.props.children).trim(), disabled: Boolean(element.props.disabled), }); return; } if (element.type === Fragment || element.props.children != null) { flattenOptions(element.props.children, target); } }); } function normalized(value: string): string { return value .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .toLocaleLowerCase('es-AR') .trim(); } export function SearchableSelect({ children, className, disabled, value, defaultValue, onChange, onBlur, onFocus, searchPlaceholder = 'Buscar…', id, ...selectProps }: SearchableSelectProps) { const selectRef = useRef(null); const buttonRef = useRef(null); const searchRef = useRef(null); const popupRef = useRef(null); const generatedId = useId(); const resolvedId = id || generatedId; const [open, setOpen] = useState(false); const [query, setQuery] = useState(''); const [popupStyle, setPopupStyle] = useState({ top: 0, left: 0, width: 240 }); const options = useMemo(() => { const result: FlatOption[] = []; flattenOptions(children, result); return result; }, [children]); const selectedValue = value == null ? defaultValue == null ? '' : String(defaultValue) : String(value); const selected = options.find((option) => option.value === selectedValue); const placeholder = options.find((option) => option.value === '')?.label || 'Seleccionar…'; const queryNormalized = normalized(query); const filtered = queryNormalized ? options.filter((option) => normalized(option.label).includes(queryNormalized)) : options; const positionPopup = () => { const rect = buttonRef.current?.getBoundingClientRect(); if (!rect) return; const margin = 8; const desiredWidth = Math.max(rect.width, 260); const width = Math.min(desiredWidth, window.innerWidth - margin * 2); const left = Math.max(margin, Math.min(rect.left, window.innerWidth - width - margin)); const roomBelow = window.innerHeight - rect.bottom; const top = roomBelow >= 320 ? rect.bottom + 5 : Math.max(margin, rect.top - Math.min(315, rect.top - margin)); setPopupStyle({ top, left, width }); }; const close = () => { setOpen(false); setQuery(''); }; const choose = (nextValue: string) => { const native = selectRef.current; if (!native) return; const descriptor = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value'); descriptor?.set?.call(native, nextValue); native.dispatchEvent(new Event('change', { bubbles: true })); close(); window.setTimeout(() => buttonRef.current?.focus(), 0); }; useEffect(() => { if (!open) return; positionPopup(); const reposition = () => positionPopup(); const outside = (event: MouseEvent) => { const node = event.target as Node; if (popupRef.current?.contains(node) || buttonRef.current?.contains(node)) return; close(); }; window.addEventListener('resize', reposition); window.addEventListener('scroll', reposition, true); document.addEventListener('mousedown', outside); window.setTimeout(() => searchRef.current?.focus(), 0); return () => { window.removeEventListener('resize', reposition); window.removeEventListener('scroll', reposition, true); document.removeEventListener('mousedown', outside); }; }, [open]); return
{open && createPortal(
setQuery(event.target.value)} onKeyDown={(event) => { if (event.key === 'Escape') { event.preventDefault(); close(); buttonRef.current?.focus(); } if (event.key === 'Enter') { const first = filtered.find((option) => !option.disabled); if (first) { event.preventDefault(); choose(first.value); } } }} placeholder={searchPlaceholder} aria-label={searchPlaceholder} />
{filtered.length === 0 ?
Sin coincidencias
: filtered.map((option, index) => )}
, document.body)}
; }