List components #31
@ -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>
|
||||||
|
|||||||
@ -0,0 +1,63 @@
|
|||||||
|
import ListItem from '../ListItem/ListItem';
|
||||||
|
|
||||||
|
export interface SearchResultOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
subtitle?: string;
|
||||||
|
ansv7779 marked this conversation as resolved
ansv7779
commented
This is limiting. Often times you want to select some more complex object as you do in your examples in 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
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.