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 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}

View File

@ -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>

View File

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

View File

@ -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>
)}

View File

@ -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