List components #31

Open
stne3960 wants to merge 57 commits from list_item into main
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 { useState, useRef, useEffect, useCallback } from 'react';
import TextInput from '../TextInput/TextInput'; import TextInput from '../TextInput/TextInput';
import ListItem from '../ListItem/ListItem'; import SearchResultList, { type SearchResultOption } from '../SearchResultList/SearchResultList';
export interface ComboboxOption { export type ComboboxOption = SearchResultOption;
value: string;
label: string;
subtitle?: string;
}
export type ComboboxSize = 'sm' | 'md' | 'lg'; export type ComboboxSize = 'sm' | 'md' | 'lg';
@ -30,13 +26,7 @@ const widthClasses: Record<ComboboxSize, string> = {
lg: 'w-(--text-input-default-width-lg)', lg: 'w-(--text-input-default-width-lg)',
}; };
const dropdownClasses = [ const dropdownWrapperClasses = 'absolute top-full left-0 z-50 w-full mt-(--spacing-sm)';
'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';
function SearchIcon({ style }: { style?: React.CSSProperties }) { function SearchIcon({ style }: { style?: React.CSSProperties }) {
return ( return (
@ -102,10 +92,6 @@ function findNextSelectableIndex(
return currentIndex; return currentIndex;
} }
function isSelected(value: string, selectedValues: string[]): boolean {
return selectedValues.includes(value);
}
function getDisplayValue( function getDisplayValue(
options: ComboboxOption[], options: ComboboxOption[],
selectedValues: string[], selectedValues: string[],
@ -179,7 +165,7 @@ export default function Combobox({
const handleSelect = (option: ComboboxOption) => { const handleSelect = (option: ComboboxOption) => {
if (multiple) { if (multiple) {
// Toggle selection in multi-select mode // 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.filter((v) => v !== option.value)
: [...selectedValues, option.value]; : [...selectedValues, option.value];
onChange?.(newValues); onChange?.(newValues);
@ -245,27 +231,16 @@ export default function Combobox({
/> />
{isOpen && ( {isOpen && (
<div className={dropdownClasses} style={{ maxHeight: dropdownHeight }}> <div className={dropdownWrapperClasses}>
{filteredOptions.length > 0 ? ( <SearchResultList
filteredOptions.map((option, index) => ( options={filteredOptions}
<div selectedValues={selectedValues}
key={option.value} focusedIndex={focusedIndex}
ref={(el) => { maxHeight={dropdownHeight}
itemRefs.current[index] = el; noResultsText={noResultsText}
}} onSelect={handleSelect}
> itemRefs={itemRefs}
<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> </div>
)} )}
</div> </div>

View File

@ -0,0 +1,63 @@
import ListItem from '../ListItem/ListItem';
export interface SearchResultOption {
value: string;
label: string;
subtitle?: string;
}
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>
);
}