List components #31

Open
stne3960 wants to merge 57 commits from list_item into main
Showing only changes of commit 507e2b2d55 - Show all commits

View 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>
);
}