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 77 additions and 39 deletions
Showing only changes of commit be9b621737 - Show all commits

View File

@ -1,12 +1,8 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import TextInput from '../TextInput/TextInput';
import ListItem from '../ListItem/ListItem';
import SearchResultList, { type SearchResultOption } from '../SearchResultList/SearchResultList';
export interface ComboboxOption {
value: string;
label: string;
subtitle?: string;
}
export type ComboboxOption = SearchResultOption;
export type ComboboxSize = 'sm' | 'md' | 'lg';
@ -30,13 +26,7 @@ const widthClasses: Record<ComboboxSize, string> = {
lg: 'w-(--text-input-default-width-lg)',
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.
};
const dropdownClasses = [
'absolute top-full left-0 z-50 w-full',
'bg-base-canvas border border-base-ink-medium rounded-(--border-radius-md)',
'overflow-y-auto mt-(--spacing-sm)',
].join(' ');
const noResultsClasses = 'px-(--padding-md) py-(--padding-md) body-normal-md text-base-ink-placeholder text-center';
const dropdownWrapperClasses = 'absolute top-full left-0 z-50 w-full mt-(--spacing-sm)';
function SearchIcon({ style }: { style?: React.CSSProperties }) {
return (
@ -102,10 +92,6 @@ function findNextSelectableIndex(
return currentIndex;
}
function isSelected(value: string, selectedValues: string[]): boolean {
return selectedValues.includes(value);
}
function getDisplayValue(
options: ComboboxOption[],
selectedValues: string[],
@ -179,7 +165,7 @@ export default function Combobox({
const handleSelect = (option: ComboboxOption) => {
if (multiple) {
// Toggle selection in multi-select mode
const newValues = isSelected(option.value, selectedValues)
const newValues = selectedValues.includes(option.value)
? selectedValues.filter((v) => v !== option.value)
: [...selectedValues, option.value];
onChange?.(newValues);
@ -245,27 +231,16 @@ export default function Combobox({
/>
{isOpen && (
<div className={dropdownClasses} style={{ maxHeight: dropdownHeight }}>
{filteredOptions.length > 0 ? (
filteredOptions.map((option, index) => (
<div
key={option.value}
ref={(el) => {
itemRefs.current[index] = el;
}}
>
<ListItem
title={option.label}
subtitle={option.subtitle}
selected={isSelected(option.value, selectedValues)}
focused={index === focusedIndex}
onClick={() => handleSelect(option)}
/>
</div>
))
) : (
<div className={noResultsClasses}>{noResultsText}</div>
)}
<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,63 @@
import ListItem from '../ListItem/ListItem';
export interface SearchResultOption {
value: string;
label: string;
subtitle?: string;
ansv7779 marked this conversation as resolved
Review

This is limiting. Often times you want to select some more complex object as you do in your examples in ComponentLibrary. Forcing all users of the component to do their own lookup when it should be handled by the Combobox/this component.

This is limiting. Often times you want to select some more complex object as you do in your examples in `ComponentLibrary`. Forcing all users of the component to do their own lookup when it should be handled by the `Combobox`/this component.
}
export interface SearchResultListProps {
options: SearchResultOption[];
selectedValues?: string[];
focusedIndex?: number;
maxHeight?: number;
noResultsText?: string;
onSelect?: (option: SearchResultOption) => void;
itemRefs?: React.RefObject<(HTMLDivElement | null)[]>;
}
const containerClasses = [
'w-full bg-base-canvas border border-base-ink-medium rounded-(--border-radius-md)',
'overflow-y-auto',
].join(' ');
const noResultsClasses = 'px-(--padding-md) py-(--padding-md) body-normal-md text-base-ink-placeholder text-center';
export default function SearchResultList({
options,
selectedValues = [],
focusedIndex = -1,
maxHeight = 300,
noResultsText = 'No results found',
onSelect,
itemRefs,
}: SearchResultListProps) {
const isSelected = (value: string) => selectedValues.includes(value);
return (
<div className={containerClasses} style={{ maxHeight }}>
{options.length > 0 ? (
options.map((option, index) => (
<div
key={option.value}
ref={(el) => {
if (itemRefs?.current) {
itemRefs.current[index] = el;
}
}}
>
<ListItem
title={option.label}
subtitle={option.subtitle}
selected={isSelected(option.value)}
focused={index === focusedIndex}
onClick={() => onSelect?.(option)}
/>
</div>
))
) : (
<div className={noResultsClasses}>{noResultsText}</div>
)}
</div>
);
}