List components #31
218
frontend/src/components/Combobox/Combobox.tsx
Normal file
218
frontend/src/components/Combobox/Combobox.tsx
Normal file
@ -0,0 +1,218 @@
|
|||||||
|
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||||
|
import TextInput from '../TextInput/TextInput';
|
||||||
|
import ListItem from '../ListItem/ListItem';
|
||||||
|
|
||||||
|
export interface ComboboxOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
subtitle?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComboboxProps {
|
||||||
|
options: ComboboxOption[];
|
||||||
|
value?: string;
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
label?: string;
|
||||||
|
fullWidth?: boolean;
|
||||||
|
dropdownHeight?: number;
|
||||||
|
noResultsText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dropdownClasses = [
|
||||||
|
'absolute top-full left-0 right-0 z-10',
|
||||||
|
'bg-base-canvas border border-base-ink-medium rounded-(--border-radius-md)',
|
||||||
|
'overflow-y-auto mt-(--spacing-sm)',
|
||||||
|
].join(' ');
|
||||||
|
|
||||||
|
const noResultsClasses = 'px-(--padding-md) py-(--padding-md) body-normal-md text-base-ink-placeholder text-center';
|
||||||
|
|
||||||
|
function SearchIcon({ style }: { style?: React.CSSProperties }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
style={style}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<circle cx="11" cy="11" r="8" />
|
||||||
|
<path d="M21 21l-4.35-4.35" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function useClickOutside(ref: React.RefObject<HTMLElement | null>, onClickOutside: () => void) {
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClick = (event: MouseEvent) => {
|
||||||
|
if (ref.current && !ref.current.contains(event.target as Node)) {
|
||||||
|
onClickOutside();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClick);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClick);
|
||||||
|
}, [ref, onClickOutside]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function useScrollIntoView(index: number, refs: React.RefObject<(HTMLDivElement | null)[]>) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (index >= 0 && refs.current[index]) {
|
||||||
|
refs.current[index]?.scrollIntoView({ block: 'nearest' });
|
||||||
|
}
|
||||||
|
}, [index, refs]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterOptions(options: ComboboxOption[], searchTerm: string): ComboboxOption[] {
|
||||||
|
const term = searchTerm.toLowerCase();
|
||||||
|
return options.filter((opt) => opt.label.toLowerCase().includes(term) || opt.subtitle?.toLowerCase().includes(term));
|
||||||
|
}
|
||||||
|
|
||||||
|
function findNextSelectableIndex(
|
||||||
|
filteredOptions: ComboboxOption[],
|
||||||
|
currentIndex: number,
|
||||||
|
direction: 'up' | 'down',
|
||||||
|
selectedValue?: string,
|
||||||
|
): number {
|
||||||
|
const step = direction === 'down' ? 1 : -1;
|
||||||
|
let nextIndex = currentIndex + step;
|
||||||
|
|
||||||
|
while (nextIndex >= 0 && nextIndex < filteredOptions.length) {
|
||||||
|
if (filteredOptions[nextIndex].value !== selectedValue) {
|
||||||
|
return nextIndex;
|
||||||
|
}
|
||||||
|
nextIndex += step;
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Combobox({
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder = 'Search...',
|
||||||
|
label,
|
||||||
|
fullWidth = false,
|
||||||
|
dropdownHeight = 300,
|
||||||
|
noResultsText = 'No results found',
|
||||||
|
}: ComboboxProps) {
|
||||||
|
// State
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [focusedIndex, setFocusedIndex] = useState(-1);
|
||||||
|
|
||||||
|
// Refs
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||||
|
|
||||||
|
// Derived state
|
||||||
|
const selectedOption = options.find((opt) => opt.value === value);
|
||||||
|
const filteredOptions = filterOptions(options, searchTerm);
|
||||||
|
const displayValue = isOpen ? searchTerm : selectedOption?.label || '';
|
||||||
|
|
||||||
|
// Close dropdown and reset
|
||||||
|
const closeDropdown = useCallback(() => {
|
||||||
|
setIsOpen(false);
|
||||||
|
setSearchTerm('');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Hooks
|
||||||
|
useClickOutside(containerRef, closeDropdown);
|
||||||
|
useScrollIntoView(focusedIndex, itemRefs);
|
||||||
|
|
||||||
|
// Event handlers
|
||||||
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setSearchTerm(e.target.value);
|
||||||
|
setIsOpen(true);
|
||||||
|
setFocusedIndex(-1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputFocus = () => {
|
||||||
|
setIsOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelect = (option: ComboboxOption) => {
|
||||||
|
onChange?.(option.value);
|
||||||
|
closeDropdown();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
// Open dropdown on arrow down or enter when closed
|
||||||
|
if (!isOpen) {
|
||||||
|
if (e.key === 'ArrowDown' || e.key === 'Enter') {
|
||||||
|
setIsOpen(true);
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle navigation when open
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowDown':
|
||||||
|
e.preventDefault();
|
||||||
|
setFocusedIndex((prev) => findNextSelectableIndex(filteredOptions, prev, 'down', value));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'ArrowUp':
|
||||||
|
e.preventDefault();
|
||||||
|
setFocusedIndex((prev) => findNextSelectableIndex(filteredOptions, prev, 'up', value));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'Enter':
|
||||||
|
e.preventDefault();
|
||||||
|
if (focusedIndex >= 0 && focusedIndex < filteredOptions.length) {
|
||||||
|
handleSelect(filteredOptions[focusedIndex]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'Escape':
|
||||||
|
closeDropdown();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const containerClasses = fullWidth ? 'relative w-full' : 'relative w-(--text-input-default-width-md)';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className={containerClasses}>
|
||||||
|
<TextInput
|
||||||
|
value={displayValue}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
onFocus={handleInputFocus}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={placeholder}
|
||||||
|
label={label}
|
||||||
|
fullWidth
|
||||||
|
icon={<SearchIcon />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className={dropdownClasses} style={{ maxHeight: dropdownHeight }}>
|
||||||
|
{filteredOptions.length > 0 ? (
|
||||||
|
filteredOptions.map((option, index) => (
|
||||||
|
<div
|
||||||
|
key={option.value}
|
||||||
|
ref={(el) => {
|
||||||
|
itemRefs.current[index] = el;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListItem
|
||||||
|
title={option.label}
|
||||||
|
subtitle={option.subtitle}
|
||||||
|
selected={option.value === value}
|
||||||
|
focused={index === focusedIndex}
|
||||||
|
onClick={() => handleSelect(option)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className={noResultsClasses}>{noResultsText}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user