List components #31
@ -1,243 +1,254 @@
|
|||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect } from "react";
|
||||||
import TextInput from '../TextInput/TextInput';
|
import clsx from "clsx";
|
||||||
import SearchResultList, { type SearchResultOption } from '../SearchResultList/SearchResultList';
|
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 ComboboxOption = SearchResultOption;
|
||||||
|
|
||||||
export type ComboboxSize = 'sm' | 'md' | 'lg';
|
export type ComboboxSize = "sm" | "md" | "lg";
|
||||||
|
|
||||||
export interface ComboboxProps {
|
export interface ComboboxProps {
|
||||||
options: ComboboxOption[];
|
options: ComboboxOption[];
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
label?: string;
|
label: string;
|
||||||
size?: ComboboxSize;
|
hideLabel?: boolean;
|
||||||
fullWidth?: boolean;
|
size?: ComboboxSize;
|
||||||
customWidth?: string;
|
fullWidth?: boolean;
|
||||||
dropdownHeight?: number;
|
customWidth?: string;
|
||||||
noResultsText?: string;
|
dropdownHeight?: number;
|
||||||
multiple?: boolean;
|
noResultsText?: string;
|
||||||
value?: string | string[];
|
multiple?: boolean;
|
||||||
onChange?: (value: string | string[]) => void;
|
value?: string | string[];
|
||||||
/** Called when search term changes. When provided, local filtering is disabled (assumes API filtering). */
|
onChange?: (value: string | string[]) => void;
|
||||||
|
|
|||||||
onSearchChange?: (term: 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> = {
|
||||||
sm: 'w-(--text-input-default-width-md)',
|
sm: "w-(--text-input-default-width-md)",
|
||||||
md: 'w-(--text-input-default-width-md)',
|
md: "w-(--text-input-default-width-md)",
|
||||||
lg: 'w-(--text-input-default-width-lg)',
|
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 (
|
* Scrolls the focused item into view when navigating with arrow keys.
|
||||||
<svg
|
* Uses "nearest" to minimize scrolling - only scrolls if the item is outside the visible area.
|
||||||
className={className}
|
*/
|
||||||
viewBox="0 0 24 24"
|
function useScrollIntoView(
|
||||||
fill="none"
|
index: number,
|
||||||
stroke="currentColor"
|
refs: React.RefObject<(HTMLDivElement | null)[]>,
|
||||||
strokeWidth="2"
|
) {
|
||||||
strokeLinecap="round"
|
useEffect(() => {
|
||||||
strokeLinejoin="round"
|
if (index >= 0 && refs.current[index]) {
|
||||||
>
|
refs.current[index]?.scrollIntoView({ block: "nearest" });
|
||||||
<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 '';
|
|
||||||
}
|
}
|
||||||
|
}, [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({
|
export default function Combobox({
|
||||||
options,
|
options,
|
||||||
placeholder = 'Search...',
|
placeholder = "Search...",
|
||||||
label,
|
label,
|
||||||
size = 'md',
|
hideLabel = false,
|
||||||
fullWidth = false,
|
size = "md",
|
||||||
customWidth,
|
fullWidth = false,
|
||||||
dropdownHeight = 300,
|
customWidth,
|
||||||
noResultsText = 'No results found',
|
dropdownHeight = 300,
|
||||||
multiple = false,
|
noResultsText = "No results found",
|
||||||
value,
|
multiple = false,
|
||||||
onChange,
|
value,
|
||||||
onSearchChange,
|
onChange,
|
||||||
|
onSearchChange,
|
||||||
}: ComboboxProps) {
|
}: ComboboxProps) {
|
||||||
// Normalize value to array for internal use
|
// Convert value (undefined | string | string[]) to always be an array
|
||||||
const selectedValues: string[] = value === undefined ? [] : Array.isArray(value) ? value : [value];
|
const selectedValues: string[] =
|
||||||
|
value === undefined ? [] : Array.isArray(value) ? value : [value];
|
||||||
|
|
||||||
// State
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [focusedIndex, setFocusedIndex] = useState(-1);
|
||||||
const [focusedIndex, setFocusedIndex] = useState(-1);
|
|
||||||
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
|
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||||
|
|
||||||
// Derived state - skip local filtering when onSearchChange is provided (API handles filtering)
|
// Derived state - skip local filtering when onSearchChange is provided (API handles filtering)
|
||||||
const filteredOptions = onSearchChange ? options : filterOptions(options, searchTerm);
|
const filteredOptions = onSearchChange
|
||||||
const displayValue = getDisplayValue(options, selectedValues, multiple, searchTerm);
|
? options
|
||||||
|
: filterOptions(options, searchTerm);
|
||||||
|
const displayValue = getDisplayValue(
|
||||||
|
options,
|
||||||
|
selectedValues,
|
||||||
|
multiple,
|
||||||
|
searchTerm,
|
||||||
|
);
|
||||||
|
|
||||||
const closeDropdown = () => setIsOpen(false);
|
const closeDropdown = () => setIsOpen(false);
|
||||||
|
|
||||||
// Hooks
|
useClickOutside(containerRef, closeDropdown);
|
||||||
useClickOutside(containerRef, closeDropdown);
|
useScrollIntoView(focusedIndex, itemRefs);
|
||||||
useScrollIntoView(focusedIndex, itemRefs);
|
|
||||||
|
|
||||||
// Event handlers
|
// Event handlers
|
||||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const value = e.target.value;
|
const value = e.target.value;
|
||||||
setSearchTerm(value);
|
setSearchTerm(value);
|
||||||
onSearchChange?.(value);
|
onSearchChange?.(value);
|
||||||
|
|
||||||
if (value === '') {
|
if (value === "") {
|
||||||
// Clear selection when user empties the field in single-select mode
|
// Clear selection when user empties the field in single-select mode
|
||||||
if (!multiple && selectedValues.length > 0) {
|
if (!multiple && selectedValues.length > 0) {
|
||||||
onChange?.('');
|
onChange?.("");
|
||||||
}
|
}
|
||||||
closeDropdown();
|
closeDropdown();
|
||||||
} else {
|
} else {
|
||||||
setIsOpen(true);
|
setIsOpen(true);
|
||||||
setFocusedIndex(-1);
|
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) => {
|
// Handle navigation when open
|
||||||
if (multiple) {
|
switch (e.key) {
|
||||||
// Toggle selection in multi-select mode
|
case "ArrowDown":
|
||||||
const newValues = selectedValues.includes(option.value)
|
e.preventDefault();
|
||||||
? selectedValues.filter((v) => v !== option.value)
|
setFocusedIndex((prev) =>
|
||||||
: [...selectedValues, option.value];
|
getNextIndex(prev, "down", filteredOptions.length - 1),
|
||||||
onChange?.(newValues);
|
);
|
||||||
} else {
|
break;
|
||||||
// Replace selection in single-select mode
|
|
||||||
onChange?.(option.value);
|
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();
|
closeDropdown();
|
||||||
};
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const containerClasses = clsx(
|
||||||
// Open dropdown on arrow keys when closed (only if we have options to show)
|
"relative",
|
||||||
if (!isOpen) {
|
fullWidth && "w-full",
|
||||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
!fullWidth && !customWidth && widthClasses[size],
|
||||||
if (filteredOptions.length > 0) {
|
);
|
||||||
setIsOpen(true);
|
const widthStyle = customWidth ? { width: customWidth } : undefined;
|
||||||
}
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle navigation when open
|
return (
|
||||||
switch (e.key) {
|
<div ref={containerRef} className={containerClasses} style={widthStyle}>
|
||||||
case 'ArrowDown':
|
<TextInput
|
||||||
e.preventDefault();
|
value={displayValue}
|
||||||
setFocusedIndex((prev) => getNextIndex(prev, 'down', filteredOptions.length - 1));
|
onChange={handleInputChange}
|
||||||
break;
|
onBlur={closeDropdown}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={placeholder}
|
||||||
|
label={label}
|
||||||
|
hideLabel={hideLabel}
|
||||||
|
size={size}
|
||||||
|
fullWidth={fullWidth || !!customWidth}
|
||||||
|
customWidth={customWidth}
|
||||||
|
Icon={SearchIcon}
|
||||||
|
/>
|
||||||
|
|
||||||
case 'ArrowUp':
|
{isOpen && (
|
||||||
e.preventDefault();
|
<div className={dropdownWrapperClasses}>
|
||||||
setFocusedIndex((prev) => getNextIndex(prev, 'up', filteredOptions.length - 1));
|
<SearchResultList
|
||||||
break;
|
options={filteredOptions}
|
||||||
|
selectedValues={selectedValues}
|
||||||
case 'Enter':
|
focusedIndex={focusedIndex}
|
||||||
e.preventDefault();
|
maxHeight={dropdownHeight}
|
||||||
if (focusedIndex >= 0 && focusedIndex < filteredOptions.length) {
|
noResultsText={noResultsText}
|
||||||
handleSelect(filteredOptions[focusedIndex]);
|
onSelect={handleSelect}
|
||||||
}
|
itemRefs={itemRefs}
|
||||||
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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
17
frontend/src/hooks/useClickOutside.ts
Normal file
17
frontend/src/hooks/useClickOutside.ts
Normal 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]);
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user
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 myonChangecallback to be called with an array of choices.