List components #31

Open
stne3960 wants to merge 57 commits from list_item into main
5 changed files with 66 additions and 63 deletions
Showing only changes of commit 9e9f386871 - Show all commits

View File

@ -1,4 +1,4 @@
import { useState, useRef, useEffect, useCallback } from 'react'; import { useState, useRef, useEffect } from 'react';
import TextInput from '../TextInput/TextInput'; import TextInput from '../TextInput/TextInput';
import SearchResultList, { type SearchResultOption } from '../SearchResultList/SearchResultList'; import SearchResultList, { type SearchResultOption } from '../SearchResultList/SearchResultList';
@ -18,6 +18,8 @@ export interface ComboboxProps {
multiple?: boolean; multiple?: boolean;
value?: string | string[]; value?: string | string[];
onChange?: (value: string | string[]) => void; 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> = { const widthClasses: Record<ComboboxSize, string> = {
@ -68,48 +70,30 @@ function useScrollIntoView(index: number, refs: React.RefObject<(HTMLDivElement
function filterOptions(options: ComboboxOption[], searchTerm: string): ComboboxOption[] { function filterOptions(options: ComboboxOption[], searchTerm: string): ComboboxOption[] {
const term = searchTerm.toLowerCase(); 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( function getNextIndex(currentIndex: number, direction: 'up' | 'down', maxIndex: number): number {
filteredOptions: ComboboxOption[], const next = currentIndex + (direction === 'down' ? 1 : -1);
currentIndex: number, return Math.max(0, Math.min(next, maxIndex));
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 getDisplayValue( function getDisplayValue(
options: ComboboxOption[], options: ComboboxOption[],
selectedValues: string[], selectedValues: string[],
multiple: boolean, multiple: boolean,
isOpen: boolean,
searchTerm: string, searchTerm: string,
): string { ): string {
if (isOpen) return searchTerm; if (searchTerm) return searchTerm;
if (selectedValues.length === 0) return '';
if (multiple) { if (multiple) {
if (selectedValues.length === 1) { return '';
return options.find((opt) => opt.value === selectedValues[0])?.label || '';
}
return `${selectedValues.length} selected`;
} }
if (selectedValues.length === 0) return '';
return options.find((opt) => opt.value === selectedValues[0])?.label || ''; return options.find((opt) => opt.value === selectedValues[0])?.label || '';
} }
@ -125,6 +109,7 @@ export default function Combobox({
multiple = false, multiple = false,
value, value,
onChange, onChange,
onSearchChange,
}: ComboboxProps) { }: ComboboxProps) {
// Normalize value to array for internal use // Normalize value to array for internal use
const selectedValues: string[] = value === undefined ? [] : Array.isArray(value) ? value : [value]; const selectedValues: string[] = value === undefined ? [] : Array.isArray(value) ? value : [value];
@ -137,15 +122,11 @@ export default function Combobox({
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const itemRefs = useRef<(HTMLDivElement | null)[]>([]); const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
// Derived state // Derived state - skip local filtering when onSearchChange is provided (API handles filtering)
const filteredOptions = filterOptions(options, searchTerm); const filteredOptions = onSearchChange ? options : filterOptions(options, searchTerm);
const displayValue = getDisplayValue(options, selectedValues, multiple, isOpen, searchTerm); const displayValue = getDisplayValue(options, selectedValues, multiple, searchTerm);
// Close dropdown and reset const closeDropdown = () => setIsOpen(false);
const closeDropdown = useCallback(() => {
setIsOpen(false);
setSearchTerm('');
}, []);
// Hooks // Hooks
useClickOutside(containerRef, closeDropdown); useClickOutside(containerRef, closeDropdown);
@ -153,13 +134,20 @@ export default function Combobox({
// Event handlers // Event handlers
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearchTerm(e.target.value); const value = e.target.value;
setSearchTerm(value);
onSearchChange?.(value);
if (value === '') {
// Clear selection when user empties the field in single-select mode
if (!multiple && selectedValues.length > 0) {
onChange?.('');
}
closeDropdown();
} else {
setIsOpen(true); setIsOpen(true);
setFocusedIndex(-1); setFocusedIndex(-1);
}; }
const handleInputFocus = () => {
setIsOpen(true);
}; };
const handleSelect = (option: ComboboxOption) => { const handleSelect = (option: ComboboxOption) => {
@ -169,19 +157,21 @@ export default function Combobox({
? selectedValues.filter((v) => v !== option.value) ? selectedValues.filter((v) => v !== option.value)
: [...selectedValues, option.value]; : [...selectedValues, option.value];
onChange?.(newValues); onChange?.(newValues);
// Keep dropdown open in multi-select mode
} else { } else {
// Replace selection in single-select mode // Replace selection in single-select mode
onChange?.(option.value); onChange?.(option.value);
closeDropdown();
} }
setSearchTerm(''); // Clear search so selected label shows
closeDropdown();
}; };
const handleKeyDown = (e: React.KeyboardEvent) => { 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 (!isOpen) {
if (e.key === 'ArrowDown' || e.key === 'Enter') { if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
if (filteredOptions.length > 0) {
setIsOpen(true); setIsOpen(true);
}
e.preventDefault(); e.preventDefault();
} }
return; return;
@ -191,12 +181,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', selectedValues, multiple)); setFocusedIndex((prev) => getNextIndex(prev, 'down', filteredOptions.length - 1));
break; break;
case 'ArrowUp': case 'ArrowUp':
e.preventDefault(); e.preventDefault();
setFocusedIndex((prev) => findNextSelectableIndex(filteredOptions, prev, 'up', selectedValues, multiple)); setFocusedIndex((prev) => getNextIndex(prev, 'up', filteredOptions.length - 1));
break; break;
case 'Enter': case 'Enter':
@ -207,12 +197,17 @@ export default function Combobox({
break; break;
case 'Escape': case 'Escape':
case 'Tab':
closeDropdown(); closeDropdown();
break; 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; const containerStyle = customWidth ? { width: customWidth } : undefined;
return ( return (
@ -220,7 +215,7 @@ export default function Combobox({
<TextInput <TextInput
value={displayValue} value={displayValue}
onChange={handleInputChange} onChange={handleInputChange}
onFocus={handleInputFocus} onBlur={closeDropdown}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder={placeholder} placeholder={placeholder}
label={label} label={label}

View File

@ -9,7 +9,7 @@ const baseClasses = [
'px-(--padding-md) py-(--padding-md)', 'px-(--padding-md) py-(--padding-md)',
'bg-sky-35 border border-sky-100 rounded-(--border-radius-md)', 'bg-sky-35 border border-sky-100 rounded-(--border-radius-md)',
'flex items-center justify-between', '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(' '); ].join(' ');
const iconStyles: CSSProperties = { const iconStyles: CSSProperties = {
@ -38,13 +38,13 @@ export default function ListCard({ title, onRemove, className = '', ...props }:
const classes = [baseClasses, className].filter(Boolean).join(' '); const classes = [baseClasses, className].filter(Boolean).join(' ');
return ( return (
<div className={classes} tabIndex={0} {...props}> <div className={classes} {...props}>
<span className="body-light-sm text-base-ink-strong">{title}</span> <span className="body-light-sm text-base-ink-strong">{title}</span>
{onRemove && ( {onRemove && (
<button <button
type="button" type="button"
onClick={onRemove} 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} /> <RemoveIcon style={iconStyles} />
</button> </button>

View File

@ -56,7 +56,7 @@ export default function ListItem({
return ( return (
<div <div
className={classes} className={classes}
tabIndex={0} tabIndex={-1}
role="option" role="option"
aria-selected={selected} aria-selected={selected}
{...props} {...props}

View File

@ -11,6 +11,8 @@ export interface ParticipantPickerProps {
size?: ComboboxSize; size?: ComboboxSize;
fullWidth?: boolean; fullWidth?: boolean;
customWidth?: string; 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> = { const widthClasses: Record<ComboboxSize, string> = {
@ -29,6 +31,7 @@ export default function ParticipantPicker({
size = 'md', size = 'md',
fullWidth = false, fullWidth = false,
customWidth, customWidth,
onSearchChange,
}: ParticipantPickerProps) { }: ParticipantPickerProps) {
const handleRemove = (valueToRemove: string) => { const handleRemove = (valueToRemove: string) => {
onChange(value.filter((v) => v !== valueToRemove)); onChange(value.filter((v) => v !== valueToRemove));
@ -36,7 +39,11 @@ export default function ParticipantPicker({
const selectedOptions = options.filter((opt) => value.includes(opt.value)); 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; const containerStyle = customWidth ? { width: customWidth } : undefined;
return ( return (
@ -51,15 +58,12 @@ export default function ParticipantPicker({
size={size} size={size}
fullWidth fullWidth
multiple multiple
onSearchChange={onSearchChange}
/> />
{selectedOptions.length > 0 && ( {selectedOptions.length > 0 && (
<div className="flex flex-col gap-(--spacing-sm)"> <div className="flex flex-col gap-(--spacing-sm)">
{selectedOptions.map((option) => ( {selectedOptions.map((option) => (
<ListCard <ListCard key={option.value} title={option.label} onRemove={() => handleRemove(option.value)} />
key={option.value}
title={option.label}
onRemove={() => handleRemove(option.value)}
/>
))} ))}
</div> </div>
)} )}

View File

@ -35,7 +35,11 @@ export default function SearchResultList({
const isSelected = (value: string) => selectedValues.includes(value); const isSelected = (value: string) => selectedValues.includes(value);
return ( return (
<div className={containerClasses} style={{ maxHeight }}> <div
className={containerClasses}
style={{ maxHeight }}
onMouseDown={(e) => e.preventDefault()}
>
{options.length > 0 ? ( {options.length > 0 ? (
options.map((option, index) => ( options.map((option, index) => (
<div <div