229 lines
7.2 KiB
TypeScript
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>;
|
|
}
|