List components #31
@ -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)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user