Files
dh-inspeccion-v2/web-v2/src/components/SearchableSelect.tsx
T

229 lines
7.2 KiB
TypeScript

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<HTMLSelectElement> & {
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<HTMLSelectElement | null>(null);
const buttonRef = useRef<HTMLButtonElement | null>(null);
const searchRef = useRef<HTMLInputElement | null>(null);
const popupRef = useRef<HTMLDivElement | null>(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 <div className={`searchable-select${className ? ` ${className}` : ''}`}>
<select
{...selectProps}
ref={selectRef}
id={`${resolvedId}-native`}
className="searchable-select-native"
disabled={disabled}
value={value}
defaultValue={defaultValue}
onChange={onChange}
onBlur={onBlur}
onFocus={onFocus}
tabIndex={-1}
aria-hidden="true"
>{children}</select>
<button
ref={buttonRef}
id={resolvedId}
type="button"
className="searchable-select-trigger"
disabled={disabled}
aria-haspopup="listbox"
aria-expanded={open}
onClick={() => {
if (disabled) return;
positionPopup();
setOpen((current) => !current);
}}
>
<span className={!selectedValue ? 'placeholder' : ''}>{selected?.label || placeholder}</span>
<Icon name="chevron" size={14} />
</button>
{open && createPortal(<div
ref={popupRef}
className="searchable-select-popup"
style={{ top: popupStyle.top, left: popupStyle.left, width: popupStyle.width }}
>
<div className="searchable-select-search"><Icon name="search" size={14} /><input
ref={searchRef}
value={query}
onChange={(event) => 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}
/></div>
<div className="searchable-select-options" role="listbox">
{filtered.length === 0 ? <div className="searchable-select-empty">Sin coincidencias</div> : filtered.map((option, index) => <button
type="button"
role="option"
aria-selected={option.value === selectedValue}
className={option.value === selectedValue ? 'selected' : ''}
disabled={option.disabled}
key={`${option.value}-${index}`}
onClick={() => choose(option.value)}
><span>{option.label || '—'}</span>{option.value === selectedValue && <Icon name="check" size={14} />}</button>)}
</div>
</div>, document.body)}
</div>;
}