List components #31
@ -1,4 +1,4 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import TextInput from '../TextInput/TextInput';
|
||||
import SearchResultList, { type SearchResultOption } from '../SearchResultList/SearchResultList';
|
||||
|
||||
@ -18,6 +18,8 @@ export interface ComboboxProps {
|
||||
multiple?: boolean;
|
||||
value?: string | string[];
|
||||
onChange?: (value: string | string[]) => void;
|
||||
/** Called when search term changes. When provided, local filtering is disabled (assumes API filtering). */
|
||||
onSearchChange?: (term: string) => void;
|
||||
}
|
||||
|
||||
const widthClasses: Record<ComboboxSize, string> = {
|
||||
@ -68,48 +70,30 @@ function useScrollIntoView(index: number, refs: React.RefObject<(HTMLDivElement
|
||||
|
||||
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));
|
||||
return options.filter(
|
||||
(opt) => opt.label.toLowerCase().includes(term) || opt.subtitle?.toLowerCase().includes(term),
|
||||
);
|
||||
}
|
||||
|
||||
function findNextSelectableIndex(
|
||||
filteredOptions: ComboboxOption[],
|
||||
currentIndex: number,
|
||||
direction: 'up' | 'down',
|
||||
selectedValues: string[],
|
||||
multiple: boolean,
|
||||
): number {
|
||||
const step = direction === 'down' ? 1 : -1;
|
||||
let nextIndex = currentIndex + step;
|
||||
|
||||
while (nextIndex >= 0 && nextIndex < filteredOptions.length) {
|
||||
// In single-select mode, skip already selected items
|
||||
if (multiple || !selectedValues.includes(filteredOptions[nextIndex].value)) {
|
||||
return nextIndex;
|
||||
}
|
||||
nextIndex += step;
|
||||
}
|
||||
|
||||
return currentIndex;
|
||||
function getNextIndex(currentIndex: number, direction: 'up' | 'down', maxIndex: number): number {
|
||||
const next = currentIndex + (direction === 'down' ? 1 : -1);
|
||||
return Math.max(0, Math.min(next, maxIndex));
|
||||
}
|
||||
|
||||
function getDisplayValue(
|
||||
options: ComboboxOption[],
|
||||
selectedValues: string[],
|
||||
multiple: boolean,
|
||||
isOpen: boolean,
|
||||
searchTerm: string,
|
||||
): string {
|
||||
if (isOpen) return searchTerm;
|
||||
|
||||
if (selectedValues.length === 0) return '';
|
||||
if (searchTerm) return searchTerm;
|
||||
|
||||
if (multiple) {
|
||||
if (selectedValues.length === 1) {
|
||||
return options.find((opt) => opt.value === selectedValues[0])?.label || '';
|
||||
}
|
||||
return `${selectedValues.length} selected`;
|
||||
return '';
|
||||
}
|
||||
|
||||
if (selectedValues.length === 0) return '';
|
||||
|
||||
return options.find((opt) => opt.value === selectedValues[0])?.label || '';
|
||||
}
|
||||
|
||||
@ -125,6 +109,7 @@ export default function Combobox({
|
||||
multiple = false,
|
||||
value,
|
||||
onChange,
|
||||
onSearchChange,
|
||||
}: ComboboxProps) {
|
||||
// Normalize value to array for internal use
|
||||
const selectedValues: string[] = value === undefined ? [] : Array.isArray(value) ? value : [value];
|
||||
@ -137,15 +122,11 @@ export default function Combobox({
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
|
||||
// Derived state
|
||||
const filteredOptions = filterOptions(options, searchTerm);
|
||||
const displayValue = getDisplayValue(options, selectedValues, multiple, isOpen, searchTerm);
|
||||
// Derived state - skip local filtering when onSearchChange is provided (API handles filtering)
|
||||
const filteredOptions = onSearchChange ? options : filterOptions(options, searchTerm);
|
||||
const displayValue = getDisplayValue(options, selectedValues, multiple, searchTerm);
|
||||
|
||||
// Close dropdown and reset
|
||||
const closeDropdown = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
setSearchTerm('');
|
||||
}, []);
|
||||
const closeDropdown = () => setIsOpen(false);
|
||||
|
||||
// Hooks
|
||||
useClickOutside(containerRef, closeDropdown);
|
||||
@ -153,13 +134,20 @@ export default function Combobox({
|
||||
|
||||
// Event handlers
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchTerm(e.target.value);
|
||||
setIsOpen(true);
|
||||
setFocusedIndex(-1);
|
||||
};
|
||||
const value = e.target.value;
|
||||
setSearchTerm(value);
|
||||
onSearchChange?.(value);
|
||||
|
||||
const handleInputFocus = () => {
|
||||
setIsOpen(true);
|
||||
if (value === '') {
|
||||
// Clear selection when user empties the field in single-select mode
|
||||
if (!multiple && selectedValues.length > 0) {
|
||||
onChange?.('');
|
||||
}
|
||||
closeDropdown();
|
||||
} else {
|
||||
setIsOpen(true);
|
||||
setFocusedIndex(-1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelect = (option: ComboboxOption) => {
|
||||
@ -169,19 +157,21 @@ export default function Combobox({
|
||||
? 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);
|
||||
closeDropdown();
|
||||
}
|
||||
setSearchTerm(''); // Clear search so selected label shows
|
||||
closeDropdown();
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
// Open dropdown on arrow down or enter when closed
|
||||
// Open dropdown on arrow keys when closed (only if we have options to show)
|
||||
if (!isOpen) {
|
||||
if (e.key === 'ArrowDown' || e.key === 'Enter') {
|
||||
setIsOpen(true);
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
if (filteredOptions.length > 0) {
|
||||
setIsOpen(true);
|
||||
}
|
||||
e.preventDefault();
|
||||
}
|
||||
return;
|
||||
@ -191,12 +181,12 @@ export default function Combobox({
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
setFocusedIndex((prev) => findNextSelectableIndex(filteredOptions, prev, 'down', selectedValues, multiple));
|
||||
setFocusedIndex((prev) => getNextIndex(prev, 'down', filteredOptions.length - 1));
|
||||
break;
|
||||
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
setFocusedIndex((prev) => findNextSelectableIndex(filteredOptions, prev, 'up', selectedValues, multiple));
|
||||
setFocusedIndex((prev) => getNextIndex(prev, 'up', filteredOptions.length - 1));
|
||||
break;
|
||||
|
||||
case 'Enter':
|
||||
@ -207,12 +197,17 @@ export default function Combobox({
|
||||
break;
|
||||
|
||||
case 'Escape':
|
||||
case 'Tab':
|
||||
closeDropdown();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const containerClasses = fullWidth ? 'relative w-full' : customWidth ? 'relative' : `relative ${widthClasses[size]}`;
|
||||
const containerClasses = fullWidth
|
||||
? 'relative w-full'
|
||||
: customWidth
|
||||
? 'relative'
|
||||
: `relative ${widthClasses[size]}`;
|
||||
const containerStyle = customWidth ? { width: customWidth } : undefined;
|
||||
|
||||
return (
|
||||
@ -220,7 +215,7 @@ export default function Combobox({
|
||||
<TextInput
|
||||
value={displayValue}
|
||||
onChange={handleInputChange}
|
||||
onFocus={handleInputFocus}
|
||||
onBlur={closeDropdown}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
label={label}
|
||||
|
||||
@ -9,7 +9,7 @@ const baseClasses = [
|
||||
'px-(--padding-md) py-(--padding-md)',
|
||||
'bg-sky-35 border border-sky-100 rounded-(--border-radius-md)',
|
||||
'flex items-center justify-between',
|
||||
'focus:border-primary focus:outline focus:outline-sky-35 focus:outline-(length:--border-width-lg)',
|
||||
'focus:border-primary focus:outline focus:outline-sky-35 focus:outline-[length:var(--border-width-lg)]',
|
||||
].join(' ');
|
||||
|
||||
const iconStyles: CSSProperties = {
|
||||
@ -38,13 +38,13 @@ export default function ListCard({ title, onRemove, className = '', ...props }:
|
||||
const classes = [baseClasses, className].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<div className={classes} tabIndex={0} {...props}>
|
||||
<div className={classes} {...props}>
|
||||
<span className="body-light-sm text-base-ink-strong">{title}</span>
|
||||
{onRemove && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className="shrink-0 ml-(--spacing-sm) text-base-ink-placeholder hover:text-primary focus:outline-none cursor-pointer"
|
||||
className="shrink-0 ml-(--spacing-sm) text-base-ink-placeholder hover:text-primary focus:text-primary focus:outline-none cursor-pointer"
|
||||
>
|
||||
<RemoveIcon style={iconStyles} />
|
||||
</button>
|
||||
|
||||
@ -56,7 +56,7 @@ export default function ListItem({
|
||||
return (
|
||||
<div
|
||||
className={classes}
|
||||
tabIndex={0}
|
||||
tabIndex={-1}
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
{...props}
|
||||
|
||||
@ -11,6 +11,8 @@ export interface ParticipantPickerProps {
|
||||
size?: ComboboxSize;
|
||||
fullWidth?: boolean;
|
||||
customWidth?: string;
|
||||
/** Called when search term changes. When provided, local filtering is disabled (assumes API filtering). */
|
||||
onSearchChange?: (term: string) => void;
|
||||
}
|
||||
|
||||
const widthClasses: Record<ComboboxSize, string> = {
|
||||
@ -29,6 +31,7 @@ export default function ParticipantPicker({
|
||||
size = 'md',
|
||||
fullWidth = false,
|
||||
customWidth,
|
||||
onSearchChange,
|
||||
}: ParticipantPickerProps) {
|
||||
const handleRemove = (valueToRemove: string) => {
|
||||
onChange(value.filter((v) => v !== valueToRemove));
|
||||
@ -36,7 +39,11 @@ export default function ParticipantPicker({
|
||||
|
||||
const selectedOptions = options.filter((opt) => value.includes(opt.value));
|
||||
|
||||
const containerClasses = fullWidth ? 'flex flex-col gap-(--spacing-sm) w-full' : customWidth ? 'flex flex-col gap-(--spacing-sm)' : `flex flex-col gap-(--spacing-sm) ${widthClasses[size]}`;
|
||||
const containerClasses = fullWidth
|
||||
? 'flex flex-col gap-(--spacing-sm) w-full'
|
||||
: customWidth
|
||||
? 'flex flex-col gap-(--spacing-sm)'
|
||||
: `flex flex-col gap-(--spacing-sm) ${widthClasses[size]}`;
|
||||
const containerStyle = customWidth ? { width: customWidth } : undefined;
|
||||
|
||||
return (
|
||||
@ -51,15 +58,12 @@ export default function ParticipantPicker({
|
||||
size={size}
|
||||
fullWidth
|
||||
multiple
|
||||
onSearchChange={onSearchChange}
|
||||
/>
|
||||
{selectedOptions.length > 0 && (
|
||||
<div className="flex flex-col gap-(--spacing-sm)">
|
||||
{selectedOptions.map((option) => (
|
||||
<ListCard
|
||||
key={option.value}
|
||||
title={option.label}
|
||||
onRemove={() => handleRemove(option.value)}
|
||||
/>
|
||||
<ListCard key={option.value} title={option.label} onRemove={() => handleRemove(option.value)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -35,7 +35,11 @@ export default function SearchResultList({
|
||||
const isSelected = (value: string) => selectedValues.includes(value);
|
||||
|
||||
return (
|
||||
<div className={containerClasses} style={{ maxHeight }}>
|
||||
<div
|
||||
className={containerClasses}
|
||||
style={{ maxHeight }}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
{options.length > 0 ? (
|
||||
options.map((option, index) => (
|
||||
<div
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user