List components #31

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

View File

@ -8,19 +8,30 @@ export interface ComboboxOption {
subtitle?: string; subtitle?: string;
} }
export type ComboboxSize = 'sm' | 'md' | 'lg';
export interface ComboboxProps { export interface ComboboxProps {
options: ComboboxOption[]; options: ComboboxOption[];
value?: string;
onChange?: (value: string) => void;
placeholder?: string; placeholder?: string;
label?: string; label?: string;
size?: ComboboxSize;
fullWidth?: boolean; fullWidth?: boolean;
customWidth?: string;
dropdownHeight?: number; dropdownHeight?: number;
noResultsText?: string; noResultsText?: string;
multiple?: boolean;
value?: string | string[];
onChange?: (value: string | string[]) => void;
} }
const widthClasses: Record<ComboboxSize, string> = {
sm: 'w-(--text-input-default-width-md)',
md: 'w-(--text-input-default-width-md)',
lg: 'w-(--text-input-default-width-lg)',
};
const dropdownClasses = [ const dropdownClasses = [
'absolute top-full left-0 right-0 z-10', 'absolute top-full left-0 z-50 w-full',
'bg-base-canvas border border-base-ink-medium rounded-(--border-radius-md)', 'bg-base-canvas border border-base-ink-medium rounded-(--border-radius-md)',
'overflow-y-auto mt-(--spacing-sm)', 'overflow-y-auto mt-(--spacing-sm)',
].join(' '); ].join(' ');
@ -74,13 +85,15 @@ function findNextSelectableIndex(
filteredOptions: ComboboxOption[], filteredOptions: ComboboxOption[],
currentIndex: number, currentIndex: number,
direction: 'up' | 'down', direction: 'up' | 'down',
selectedValue?: string, selectedValues: string[],
multiple: boolean,
): number { ): number {
const step = direction === 'down' ? 1 : -1; const step = direction === 'down' ? 1 : -1;
let nextIndex = currentIndex + step; let nextIndex = currentIndex + step;
while (nextIndex >= 0 && nextIndex < filteredOptions.length) { while (nextIndex >= 0 && nextIndex < filteredOptions.length) {
if (filteredOptions[nextIndex].value !== selectedValue) { // In single-select mode, skip already selected items
if (multiple || !selectedValues.includes(filteredOptions[nextIndex].value)) {
return nextIndex; return nextIndex;
} }
nextIndex += step; nextIndex += step;
@ -89,29 +102,58 @@ function findNextSelectableIndex(
return currentIndex; return currentIndex;
} }
function isSelected(value: string, selectedValues: string[]): boolean {
return selectedValues.includes(value);
}
function getDisplayValue(
options: ComboboxOption[],
selectedValues: string[],
multiple: boolean,
isOpen: boolean,
searchTerm: string,
): string {
if (isOpen) return searchTerm;
if (selectedValues.length === 0) return '';
if (multiple) {
if (selectedValues.length === 1) {
return options.find((opt) => opt.value === selectedValues[0])?.label || '';
}
return `${selectedValues.length} selected`;
}
return options.find((opt) => opt.value === selectedValues[0])?.label || '';
}
export default function Combobox({ export default function Combobox({
options, options,
value,
onChange,
placeholder = 'Search...', placeholder = 'Search...',
label, label,
size = 'md',
fullWidth = false, fullWidth = false,
customWidth,
dropdownHeight = 300, dropdownHeight = 300,
noResultsText = 'No results found', noResultsText = 'No results found',
multiple = false,
value,
onChange,
}: ComboboxProps) { }: ComboboxProps) {
// Normalize value to array for internal use
const selectedValues: string[] = value === undefined ? [] : Array.isArray(value) ? value : [value];
// State // State
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [focusedIndex, setFocusedIndex] = useState(-1); const [focusedIndex, setFocusedIndex] = useState(-1);
// Refs
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const itemRefs = useRef<(HTMLDivElement | null)[]>([]); const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
// Derived state // Derived state
const selectedOption = options.find((opt) => opt.value === value);
const filteredOptions = filterOptions(options, searchTerm); const filteredOptions = filterOptions(options, searchTerm);
const displayValue = isOpen ? searchTerm : selectedOption?.label || ''; const displayValue = getDisplayValue(options, selectedValues, multiple, isOpen, searchTerm);
// Close dropdown and reset // Close dropdown and reset
const closeDropdown = useCallback(() => { const closeDropdown = useCallback(() => {
@ -135,8 +177,18 @@ export default function Combobox({
}; };
const handleSelect = (option: ComboboxOption) => { const handleSelect = (option: ComboboxOption) => {
if (multiple) {
// Toggle selection in multi-select mode
const newValues = isSelected(option.value, selectedValues)
? selectedValues.filter((v) => v !== option.value)
: [...selectedValues, option.value];
onChange?.(newValues);
// Keep dropdown open in multi-select mode
} else {
// Replace selection in single-select mode
onChange?.(option.value); onChange?.(option.value);
closeDropdown(); closeDropdown();
}
}; };
const handleKeyDown = (e: React.KeyboardEvent) => { const handleKeyDown = (e: React.KeyboardEvent) => {
@ -153,12 +205,12 @@ export default function Combobox({
switch (e.key) { switch (e.key) {
case 'ArrowDown': case 'ArrowDown':
e.preventDefault(); e.preventDefault();
setFocusedIndex((prev) => findNextSelectableIndex(filteredOptions, prev, 'down', value)); setFocusedIndex((prev) => findNextSelectableIndex(filteredOptions, prev, 'down', selectedValues, multiple));
break; break;
case 'ArrowUp': case 'ArrowUp':
e.preventDefault(); e.preventDefault();
setFocusedIndex((prev) => findNextSelectableIndex(filteredOptions, prev, 'up', value)); setFocusedIndex((prev) => findNextSelectableIndex(filteredOptions, prev, 'up', selectedValues, multiple));
break; break;
case 'Enter': case 'Enter':
@ -174,10 +226,11 @@ export default function Combobox({
} }
}; };
const containerClasses = fullWidth ? 'relative w-full' : 'relative w-(--text-input-default-width-md)'; const containerClasses = fullWidth ? 'relative w-full' : customWidth ? 'relative' : `relative ${widthClasses[size]}`;
const containerStyle = customWidth ? { width: customWidth } : undefined;
return ( return (
<div ref={containerRef} className={containerClasses}> <div ref={containerRef} className={containerClasses} style={containerStyle}>
<TextInput <TextInput
value={displayValue} value={displayValue}
onChange={handleInputChange} onChange={handleInputChange}
@ -185,7 +238,9 @@ export default function Combobox({
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder={placeholder} placeholder={placeholder}
label={label} label={label}
fullWidth size={size}
fullWidth={fullWidth || !!customWidth}
customWidth={customWidth}
icon={<SearchIcon />} icon={<SearchIcon />}
/> />
@ -202,7 +257,7 @@ export default function Combobox({
<ListItem <ListItem
title={option.label} title={option.label}
subtitle={option.subtitle} subtitle={option.subtitle}
selected={option.value === value} selected={isSelected(option.value, selectedValues)}
focused={index === focusedIndex} focused={index === focusedIndex}
onClick={() => handleSelect(option)} onClick={() => handleSelect(option)}
/> />