List components #31

Merged
stne3960 merged 68 commits from list_item into main 2025-12-18 12:41:13 +01:00
2 changed files with 237 additions and 209 deletions
Showing only changes of commit 14ab479b26 - Show all commits

View File

@ -1,243 +1,254 @@
import { useState, useRef, useEffect } from 'react';
import TextInput from '../TextInput/TextInput';
import SearchResultList, { type SearchResultOption } from '../SearchResultList/SearchResultList';
import { useState, useRef, useEffect } from "react";
import clsx from "clsx";
import TextInput from "../TextInput/TextInput";
import { SearchIcon } from "../Icon/Icon";
import SearchResultList, {
type SearchResultOption,
} from "../SearchResultList/SearchResultList";
import { useClickOutside } from "../../hooks/useClickOutside";
export type ComboboxOption = SearchResultOption;
export type ComboboxSize = 'sm' | 'md' | 'lg';
export type ComboboxSize = "sm" | "md" | "lg";
export interface ComboboxProps {
options: ComboboxOption[];
placeholder?: string;
label?: string;
size?: ComboboxSize;
fullWidth?: boolean;
customWidth?: string;
dropdownHeight?: number;
noResultsText?: string;
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;
options: ComboboxOption[];
placeholder?: string;
label: string;
hideLabel?: boolean;
size?: ComboboxSize;
fullWidth?: boolean;
customWidth?: string;
dropdownHeight?: number;
noResultsText?: string;
multiple?: boolean;
value?: string | string[];
onChange?: (value: string | string[]) => void;
Review

I think this component would have been better split into two, a single select and a multiple select version. If I use it with multiple={false} I would not want my onChange callback to be called with an array of choices.

I think this component would have been better split into two, a single select and a multiple select version. If I use it with `multiple={false}` I would not want my `onChange` callback to be called with an array of choices.
/** Called when search term changes. When provided, local filtering is disabled (assumes API filtering). */
onSearchChange?: (term: 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)',
sm: "w-(--text-input-default-width-md)",
md: "w-(--text-input-default-width-md)",
lg: "w-(--text-input-default-width-lg)",
};
const dropdownWrapperClasses = 'absolute top-full left-0 z-50 w-full mt-(--spacing-sm)';
const dropdownWrapperClasses =
"absolute top-full left-0 z-50 w-full mt-(--spacing-sm)";
function SearchIcon({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="11" cy="11" r="8" />
<path d="M21 21l-4.35-4.35" />
</svg>
);
}
function useClickOutside(ref: React.RefObject<HTMLElement | null>, onClickOutside: () => void) {
useEffect(() => {
const handleClick = (event: MouseEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) {
onClickOutside();
}
};
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [ref, onClickOutside]);
}
function useScrollIntoView(index: number, refs: React.RefObject<(HTMLDivElement | null)[]>) {
useEffect(() => {
if (index >= 0 && refs.current[index]) {
refs.current[index]?.scrollIntoView({ block: 'nearest' });
}
}, [index, refs]);
}
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),
);
}
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,
searchTerm: string,
): string {
if (searchTerm) return searchTerm;
if (multiple) {
return '';
/**
* Scrolls the focused item into view when navigating with arrow keys.
* Uses "nearest" to minimize scrolling - only scrolls if the item is outside the visible area.
*/
function useScrollIntoView(
index: number,
refs: React.RefObject<(HTMLDivElement | null)[]>,
) {
useEffect(() => {
if (index >= 0 && refs.current[index]) {
refs.current[index]?.scrollIntoView({ block: "nearest" });
}
}, [index, refs]);
}
if (selectedValues.length === 0) return '';
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.find((opt) => opt.value === selectedValues[0])?.label || '';
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));
}
/**
* Determines what text to display in the input field.
* - If user is typing, show the search term
* - If multi-select mode, show nothing (selections appear as ListCards below)
* - If single-select with a selection, show the selected option's label
*/
function getDisplayValue(
options: ComboboxOption[],
selectedValues: string[],
multiple: boolean,
searchTerm: string,
): string {
if (searchTerm) return searchTerm;
if (multiple) {
return "";
}
if (selectedValues.length === 0) return "";
return options.find((opt) => opt.value === selectedValues[0])?.label || "";
}
export default function Combobox({
options,
placeholder = 'Search...',
label,
size = 'md',
fullWidth = false,
customWidth,
dropdownHeight = 300,
noResultsText = 'No results found',
multiple = false,
value,
onChange,
onSearchChange,
options,
placeholder = "Search...",
label,
hideLabel = false,
size = "md",
fullWidth = false,
customWidth,
dropdownHeight = 300,
noResultsText = "No results found",
multiple = false,
value,
onChange,
onSearchChange,
}: ComboboxProps) {
// Normalize value to array for internal use
const selectedValues: string[] = value === undefined ? [] : Array.isArray(value) ? value : [value];
// Convert value (undefined | string | string[]) to always be an array
const selectedValues: string[] =
value === undefined ? [] : Array.isArray(value) ? value : [value];
// State
const [isOpen, setIsOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [focusedIndex, setFocusedIndex] = useState(-1);
const [isOpen, setIsOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
const [focusedIndex, setFocusedIndex] = useState(-1);
const containerRef = useRef<HTMLDivElement>(null);
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
const containerRef = useRef<HTMLDivElement>(null);
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
// 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);
// 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,
);
const closeDropdown = () => setIsOpen(false);
const closeDropdown = () => setIsOpen(false);
// Hooks
useClickOutside(containerRef, closeDropdown);
useScrollIntoView(focusedIndex, itemRefs);
useClickOutside(containerRef, closeDropdown);
useScrollIntoView(focusedIndex, itemRefs);
// Event handlers
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setSearchTerm(value);
onSearchChange?.(value);
// Event handlers
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
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);
setFocusedIndex(-1);
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) => {
if (multiple) {
// Toggle selection in multi-select mode
const newValues = selectedValues.includes(option.value)
? selectedValues.filter((v) => v !== option.value)
: [...selectedValues, option.value];
onChange?.(newValues);
} else {
// Replace selection in single-select mode
onChange?.(option.value);
}
setSearchTerm(""); // Clear search so selected label shows
closeDropdown();
};
const handleKeyDown = (e: React.KeyboardEvent) => {
// Open dropdown on arrow keys when closed (only if we have options to show)
if (!isOpen) {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
if (filteredOptions.length > 0) {
setIsOpen(true);
}
};
e.preventDefault();
}
return;
}
const handleSelect = (option: ComboboxOption) => {
if (multiple) {
// Toggle selection in multi-select mode
const newValues = selectedValues.includes(option.value)
? selectedValues.filter((v) => v !== option.value)
: [...selectedValues, option.value];
onChange?.(newValues);
} else {
// Replace selection in single-select mode
onChange?.(option.value);
// Handle navigation when open
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setFocusedIndex((prev) =>
getNextIndex(prev, "down", filteredOptions.length - 1),
);
break;
case "ArrowUp":
e.preventDefault();
setFocusedIndex((prev) =>
getNextIndex(prev, "up", filteredOptions.length - 1),
);
break;
case "Enter":
e.preventDefault();
if (focusedIndex >= 0 && focusedIndex < filteredOptions.length) {
handleSelect(filteredOptions[focusedIndex]);
}
setSearchTerm(''); // Clear search so selected label shows
break;
case "Escape":
case "Tab":
closeDropdown();
};
break;
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
// Open dropdown on arrow keys when closed (only if we have options to show)
if (!isOpen) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
if (filteredOptions.length > 0) {
setIsOpen(true);
}
e.preventDefault();
}
return;
}
const containerClasses = clsx(
"relative",
fullWidth && "w-full",
!fullWidth && !customWidth && widthClasses[size],
);
const widthStyle = customWidth ? { width: customWidth } : undefined;
// Handle navigation when open
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setFocusedIndex((prev) => getNextIndex(prev, 'down', filteredOptions.length - 1));
break;
return (
<div ref={containerRef} className={containerClasses} style={widthStyle}>
<TextInput
value={displayValue}
onChange={handleInputChange}
onBlur={closeDropdown}
onKeyDown={handleKeyDown}
placeholder={placeholder}
label={label}
hideLabel={hideLabel}
size={size}
fullWidth={fullWidth || !!customWidth}
customWidth={customWidth}
Icon={SearchIcon}
/>
case 'ArrowUp':
e.preventDefault();
setFocusedIndex((prev) => getNextIndex(prev, 'up', filteredOptions.length - 1));
break;
case 'Enter':
e.preventDefault();
if (focusedIndex >= 0 && focusedIndex < filteredOptions.length) {
handleSelect(filteredOptions[focusedIndex]);
}
break;
case 'Escape':
case 'Tab':
closeDropdown();
break;
}
};
const containerClasses = fullWidth
? 'relative w-full'
: customWidth
? 'relative'
: `relative ${widthClasses[size]}`;
const widthStyle = customWidth ? { width: customWidth } : undefined;
return (
<div ref={containerRef} className={containerClasses} style={widthStyle}>
<TextInput
value={displayValue}
onChange={handleInputChange}
onBlur={closeDropdown}
onKeyDown={handleKeyDown}
placeholder={placeholder}
label={label}
size={size}
fullWidth={fullWidth || !!customWidth}
customWidth={customWidth}
icon={<SearchIcon />}
/>
{isOpen && (
<div className={dropdownWrapperClasses}>
<SearchResultList
options={filteredOptions}
selectedValues={selectedValues}
focusedIndex={focusedIndex}
maxHeight={dropdownHeight}
noResultsText={noResultsText}
onSelect={handleSelect}
itemRefs={itemRefs}
/>
</div>
)}
{isOpen && (
<div className={dropdownWrapperClasses}>
<SearchResultList
options={filteredOptions}
selectedValues={selectedValues}
focusedIndex={focusedIndex}
maxHeight={dropdownHeight}
noResultsText={noResultsText}
onSelect={handleSelect}
itemRefs={itemRefs}
/>
</div>
);
)}
</div>
);
}

View File

@ -0,0 +1,17 @@
import { useEffect } from "react";
export function useClickOutside(
ref: React.RefObject<HTMLElement | null>,
onClickOutside: () => void,
) {
useEffect(() => {
const handleClick = (event: MouseEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) {
onClickOutside();
}
};
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [ref, onClickOutside]);
}