chore: import DH V2 D5.6.4 production baseline
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
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>;
|
||||
}
|
||||
Reference in New Issue
Block a user