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