List components #31
@ -2,17 +2,16 @@ 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 SearchResultList from "../SearchResultList/SearchResultList";
|
||||
import { useClickOutside } from "../../hooks/useClickOutside";
|
||||
|
||||
export type ComboboxOption = SearchResultOption;
|
||||
|
||||
export type ComboboxSize = "sm" | "md" | "lg";
|
||||
|
||||
export interface ComboboxProps {
|
||||
options: ComboboxOption[];
|
||||
export interface ComboboxProps<T> {
|
||||
options: T[];
|
||||
getOptionValue: (option: T) => string;
|
||||
getOptionLabel: (option: T) => string;
|
||||
getOptionSubtitle?: (option: T) => string | undefined;
|
||||
placeholder?: string;
|
||||
label: string;
|
||||
hideLabel?: boolean;
|
||||
@ -52,18 +51,6 @@ function useScrollIntoView(
|
||||
}, [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",
|
||||
@ -73,31 +60,11 @@ function getNextIndex(
|
||||
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<T>({
|
||||
options,
|
||||
getOptionValue,
|
||||
getOptionLabel,
|
||||
getOptionSubtitle,
|
||||
placeholder = "Search...",
|
||||
label,
|
||||
hideLabel = false,
|
||||
@ -110,7 +77,7 @@ export default function Combobox({
|
||||
value,
|
||||
onChange,
|
||||
onSearchChange,
|
||||
}: ComboboxProps) {
|
||||
}: ComboboxProps<T>) {
|
||||
// Convert value (undefined | string | string[]) to always be an array
|
||||
const selectedValues: string[] =
|
||||
value === undefined ? [] : Array.isArray(value) ? value : [value];
|
||||
@ -122,23 +89,47 @@ export default function Combobox({
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
|
||||
/**
|
||||
* Filters options by matching search term against label and subtitle.
|
||||
* Skipped when onSearchChange is provided (API handles filtering).
|
||||
*/
|
||||
const filterOptions = (options: T[], searchTerm: string): T[] => {
|
||||
const term = searchTerm.toLowerCase();
|
||||
return options.filter((opt) => {
|
||||
const label = getOptionLabel(opt).toLowerCase();
|
||||
const subtitle = getOptionSubtitle?.(opt)?.toLowerCase() || "";
|
||||
return label.includes(term) || subtitle.includes(term);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
const getDisplayValue = (): string => {
|
||||
if (searchTerm) return searchTerm;
|
||||
if (multiple) return "";
|
||||
if (selectedValues.length === 0) return "";
|
||||
|
||||
const selected = options.find(
|
||||
(opt) => getOptionValue(opt) === selectedValues[0],
|
||||
);
|
||||
return selected ? getOptionLabel(selected) : "";
|
||||
};
|
||||
|
||||
// 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 displayValue = getDisplayValue();
|
||||
|
||||
const closeDropdown = () => setIsOpen(false);
|
||||
|
||||
useClickOutside(containerRef, closeDropdown);
|
||||
useScrollIntoView(focusedIndex, itemRefs);
|
||||
|
||||
// Event handlers
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
setSearchTerm(value);
|
||||
@ -156,16 +147,17 @@ export default function Combobox({
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelect = (option: ComboboxOption) => {
|
||||
const handleSelect = (option: T) => {
|
||||
const optionValue = getOptionValue(option);
|
||||
if (multiple) {
|
||||
// Toggle selection in multi-select mode
|
||||
const newValues = selectedValues.includes(option.value)
|
||||
? selectedValues.filter((v) => v !== option.value)
|
||||
: [...selectedValues, option.value];
|
||||
const newValues = selectedValues.includes(optionValue)
|
||||
? selectedValues.filter((v) => v !== optionValue)
|
||||
: [...selectedValues, optionValue];
|
||||
onChange?.(newValues);
|
||||
} else {
|
||||
// Replace selection in single-select mode
|
||||
onChange?.(option.value);
|
||||
onChange?.(optionValue);
|
||||
}
|
||||
setSearchTerm(""); // Clear search so selected label shows
|
||||
closeDropdown();
|
||||
@ -240,6 +232,9 @@ export default function Combobox({
|
||||
<div className={dropdownWrapperClasses}>
|
||||
<SearchResultList
|
||||
options={filteredOptions}
|
||||
getOptionValue={getOptionValue}
|
||||
getOptionLabel={getOptionLabel}
|
||||
getOptionSubtitle={getOptionSubtitle}
|
||||
selectedValues={selectedValues}
|
||||
focusedIndex={focusedIndex}
|
||||
maxHeight={dropdownHeight}
|
||||
|
||||
@ -1,14 +1,14 @@
|
||||
import type { HTMLAttributes } from "react";
|
||||
import clsx from "clsx";
|
||||
import Combobox, {
|
||||
type ComboboxOption,
|
||||
type ComboboxSize,
|
||||
} from "../Combobox/Combobox";
|
||||
import Combobox, { type ComboboxSize } from "../Combobox/Combobox";
|
||||
import ListCard from "../ListCard/ListCard";
|
||||
|
||||
export interface ParticipantPickerProps
|
||||
export interface ParticipantPickerProps<T>
|
||||
extends Omit<HTMLAttributes<HTMLDivElement>, "onChange"> {
|
||||
options: ComboboxOption[];
|
||||
options: T[];
|
||||
getOptionValue: (option: T) => string;
|
||||
getOptionLabel: (option: T) => string;
|
||||
getOptionSubtitle?: (option: T) => string | undefined;
|
||||
value: string[];
|
||||
onChange: (value: string[]) => void;
|
||||
placeholder?: string;
|
||||
@ -28,8 +28,11 @@ const widthClasses: Record<ComboboxSize, string> = {
|
||||
lg: "w-(--text-input-default-width-lg)",
|
||||
};
|
||||
|
||||
export default function ParticipantPicker({
|
||||
export default function ParticipantPicker<T>({
|
||||
options,
|
||||
getOptionValue,
|
||||
getOptionLabel,
|
||||
getOptionSubtitle,
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "Sök...",
|
||||
@ -43,12 +46,14 @@ export default function ParticipantPicker({
|
||||
className,
|
||||
style,
|
||||
...props
|
||||
}: ParticipantPickerProps) {
|
||||
}: ParticipantPickerProps<T>) {
|
||||
const handleRemove = (valueToRemove: string) => {
|
||||
onChange(value.filter((v) => v !== valueToRemove));
|
||||
};
|
||||
|
||||
const selectedOptions = options.filter((opt) => value.includes(opt.value));
|
||||
const selectedOptions = options.filter((opt) =>
|
||||
value.includes(getOptionValue(opt)),
|
||||
);
|
||||
|
||||
const containerClasses = clsx(
|
||||
"flex flex-col gap-(--spacing-sm)",
|
||||
@ -62,6 +67,9 @@ export default function ParticipantPicker({
|
||||
<div className={containerClasses} style={widthStyle} {...props}>
|
||||
<Combobox
|
||||
options={options}
|
||||
getOptionValue={getOptionValue}
|
||||
getOptionLabel={getOptionLabel}
|
||||
getOptionSubtitle={getOptionSubtitle}
|
||||
value={value}
|
||||
onChange={(v) => onChange(v as string[])}
|
||||
placeholder={placeholder}
|
||||
@ -77,10 +85,10 @@ export default function ParticipantPicker({
|
||||
<div className="flex flex-col gap-(--spacing-sm)">
|
||||
{selectedOptions.map((option) => (
|
||||
<ListCard
|
||||
key={option.value}
|
||||
title={option.label}
|
||||
subtitle={option.subtitle}
|
||||
onRemove={() => handleRemove(option.value)}
|
||||
key={getOptionValue(option)}
|
||||
title={getOptionLabel(option)}
|
||||
subtitle={getOptionSubtitle?.(option)}
|
||||
onRemove={() => handleRemove(getOptionValue(option))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -2,20 +2,17 @@ import type { HTMLAttributes } from "react";
|
||||
import clsx from "clsx";
|
||||
import ListItem from "../ListItem/ListItem";
|
||||
|
||||
export interface SearchResultOption {
|
||||
value: string;
|
||||
label: string;
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
export interface SearchResultListProps
|
||||
export interface SearchResultListProps<T>
|
||||
extends Omit<HTMLAttributes<HTMLDivElement>, "onSelect"> {
|
||||
|
ansv7779 marked this conversation as resolved
|
||||
options: SearchResultOption[];
|
||||
options: T[];
|
||||
getOptionValue: (option: T) => string;
|
||||
getOptionLabel: (option: T) => string;
|
||||
getOptionSubtitle?: (option: T) => string | undefined;
|
||||
selectedValues?: string[];
|
||||
focusedIndex?: number;
|
||||
maxHeight?: number;
|
||||
noResultsText?: string;
|
||||
onSelect?: (option: SearchResultOption) => void;
|
||||
onSelect?: (option: T) => void;
|
||||
itemRefs?: React.RefObject<(HTMLDivElement | null)[]>;
|
||||
}
|
||||
|
||||
@ -31,8 +28,11 @@ const noResultsClasses =
|
||||
const dividerClasses =
|
||||
"border-t border-base-ink-soft [border-top-width:var(--border-width-sm)]";
|
||||
|
||||
export default function SearchResultList({
|
||||
export default function SearchResultList<T>({
|
||||
options,
|
||||
getOptionValue,
|
||||
getOptionLabel,
|
||||
getOptionSubtitle,
|
||||
selectedValues = [],
|
||||
focusedIndex = -1,
|
||||
maxHeight = 300,
|
||||
@ -42,8 +42,9 @@ export default function SearchResultList({
|
||||
className,
|
||||
style,
|
||||
...props
|
||||
}: SearchResultListProps) {
|
||||
const isSelected = (value: string) => selectedValues.includes(value);
|
||||
}: SearchResultListProps<T>) {
|
||||
const isSelected = (option: T) =>
|
||||
selectedValues.includes(getOptionValue(option));
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -55,7 +56,7 @@ export default function SearchResultList({
|
||||
{options.length > 0 ? (
|
||||
options.map((option, index) => (
|
||||
<div
|
||||
key={option.value}
|
||||
key={getOptionValue(option)}
|
||||
ref={(el) => {
|
||||
if (itemRefs?.current) {
|
||||
itemRefs.current[index] = el;
|
||||
@ -64,9 +65,9 @@ export default function SearchResultList({
|
||||
className={index > 0 ? dividerClasses : undefined}
|
||||
>
|
||||
<ListItem
|
||||
title={option.label}
|
||||
subtitle={option.subtitle}
|
||||
selected={isSelected(option.value)}
|
||||
title={getOptionLabel(option)}
|
||||
subtitle={getOptionSubtitle?.(option)}
|
||||
selected={isSelected(option)}
|
||||
focused={index === focusedIndex}
|
||||
onClick={() => onSelect?.(option)}
|
||||
/>
|
||||
|
||||
@ -8,7 +8,13 @@ import Combobox from "../components/Combobox/Combobox";
|
||||
import ListCard from "../components/ListCard/ListCard";
|
||||
import ParticipantPicker from "../components/ParticipantPicker/ParticipantPicker";
|
||||
|
||||
const peopleOptions = [
|
||||
interface Person {
|
||||
value: string;
|
||||
label: string;
|
||||
subtitle: string;
|
||||
}
|
||||
|
||||
const peopleOptions: Person[] = [
|
||||
{ value: "1", label: "Lennart Johansson", subtitle: "lejo1891" },
|
||||
{ value: "2", label: "Mats Rubarth", subtitle: "matsrub1891" },
|
||||
{ value: "3", label: "Daniel Tjernström", subtitle: "datj1891" },
|
||||
@ -17,6 +23,10 @@ const peopleOptions = [
|
||||
{ value: "6", label: "Kurre Hamrin", subtitle: "kuha1891" },
|
||||
];
|
||||
|
||||
const getPersonValue = (person: Person) => person.value;
|
||||
const getPersonLabel = (person: Person) => person.label;
|
||||
const getPersonSubtitle = (person: Person) => person.subtitle;
|
||||
|
||||
export default function ComponentLibrary() {
|
||||
const [darkMode, setDarkMode] = useState(() => {
|
||||
return document.documentElement.classList.contains("dark");
|
||||
@ -206,6 +216,9 @@ export default function ComponentLibrary() {
|
||||
<div className="max-w-96">
|
||||
<SearchResultList
|
||||
options={peopleOptions}
|
||||
getOptionValue={getPersonValue}
|
||||
getOptionLabel={getPersonLabel}
|
||||
getOptionSubtitle={getPersonSubtitle}
|
||||
selectedValues={["3"]}
|
||||
focusedIndex={1}
|
||||
noResultsText="Inga resultat"
|
||||
@ -216,7 +229,12 @@ export default function ComponentLibrary() {
|
||||
<section className="mt-lg">
|
||||
<h2 className="mb-md">SearchResultList - Empty</h2>
|
||||
<div className="max-w-96">
|
||||
<SearchResultList options={[]} noResultsText="Inga resultat" />
|
||||
<SearchResultList
|
||||
options={[]}
|
||||
getOptionValue={getPersonValue}
|
||||
getOptionLabel={getPersonLabel}
|
||||
noResultsText="Inga resultat"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -225,6 +243,9 @@ export default function ComponentLibrary() {
|
||||
<div className="flex flex-col gap-md">
|
||||
<Combobox
|
||||
options={peopleOptions}
|
||||
getOptionValue={getPersonValue}
|
||||
getOptionLabel={getPersonLabel}
|
||||
getOptionSubtitle={getPersonSubtitle}
|
||||
value={selectedPerson}
|
||||
onChange={(v) => setSelectedPerson(v as string)}
|
||||
placeholder="Sök..."
|
||||
@ -244,6 +265,9 @@ export default function ComponentLibrary() {
|
||||
<div className="flex flex-col gap-md">
|
||||
<Combobox
|
||||
options={peopleOptions}
|
||||
getOptionValue={getPersonValue}
|
||||
getOptionLabel={getPersonLabel}
|
||||
getOptionSubtitle={getPersonSubtitle}
|
||||
value={selectedPeople}
|
||||
onChange={(v) => setSelectedPeople(v as string[])}
|
||||
placeholder="Sök..."
|
||||
@ -266,6 +290,9 @@ export default function ComponentLibrary() {
|
||||
<div className="flex flex-wrap items-start gap-md">
|
||||
<Combobox
|
||||
options={peopleOptions}
|
||||
getOptionValue={getPersonValue}
|
||||
getOptionLabel={getPersonLabel}
|
||||
getOptionSubtitle={getPersonSubtitle}
|
||||
placeholder="Small"
|
||||
size="sm"
|
||||
label="Small"
|
||||
@ -273,6 +300,9 @@ export default function ComponentLibrary() {
|
||||
/>
|
||||
<Combobox
|
||||
options={peopleOptions}
|
||||
getOptionValue={getPersonValue}
|
||||
getOptionLabel={getPersonLabel}
|
||||
getOptionSubtitle={getPersonSubtitle}
|
||||
placeholder="Medium"
|
||||
size="md"
|
||||
label="Medium"
|
||||
@ -280,6 +310,9 @@ export default function ComponentLibrary() {
|
||||
/>
|
||||
<Combobox
|
||||
options={peopleOptions}
|
||||
getOptionValue={getPersonValue}
|
||||
getOptionLabel={getPersonLabel}
|
||||
getOptionSubtitle={getPersonSubtitle}
|
||||
placeholder="Large"
|
||||
size="lg"
|
||||
label="Large"
|
||||
@ -293,6 +326,9 @@ export default function ComponentLibrary() {
|
||||
<div className="flex flex-col gap-md">
|
||||
<Combobox
|
||||
options={peopleOptions}
|
||||
getOptionValue={getPersonValue}
|
||||
getOptionLabel={getPersonLabel}
|
||||
getOptionSubtitle={getPersonSubtitle}
|
||||
placeholder="Sök..."
|
||||
customWidth="350px"
|
||||
label="Custom width"
|
||||
@ -300,6 +336,9 @@ export default function ComponentLibrary() {
|
||||
/>
|
||||
<Combobox
|
||||
options={peopleOptions}
|
||||
getOptionValue={getPersonValue}
|
||||
getOptionLabel={getPersonLabel}
|
||||
getOptionSubtitle={getPersonSubtitle}
|
||||
placeholder="Sök..."
|
||||
label="Full width"
|
||||
fullWidth
|
||||
@ -320,6 +359,9 @@ export default function ComponentLibrary() {
|
||||
<h2 className="mb-md">ParticipantPicker</h2>
|
||||
<ParticipantPicker
|
||||
options={peopleOptions}
|
||||
getOptionValue={getPersonValue}
|
||||
getOptionLabel={getPersonLabel}
|
||||
getOptionSubtitle={getPersonSubtitle}
|
||||
value={participants}
|
||||
onChange={setParticipants}
|
||||
placeholder="Sök deltagare..."
|
||||
@ -332,6 +374,9 @@ export default function ComponentLibrary() {
|
||||
<div className="flex flex-wrap items-start gap-md">
|
||||
<ParticipantPicker
|
||||
options={peopleOptions}
|
||||
getOptionValue={getPersonValue}
|
||||
getOptionLabel={getPersonLabel}
|
||||
getOptionSubtitle={getPersonSubtitle}
|
||||
value={participants}
|
||||
onChange={setParticipants}
|
||||
placeholder="Small"
|
||||
@ -341,6 +386,9 @@ export default function ComponentLibrary() {
|
||||
/>
|
||||
<ParticipantPicker
|
||||
options={peopleOptions}
|
||||
getOptionValue={getPersonValue}
|
||||
getOptionLabel={getPersonLabel}
|
||||
getOptionSubtitle={getPersonSubtitle}
|
||||
value={participants}
|
||||
onChange={setParticipants}
|
||||
placeholder="Medium"
|
||||
@ -350,6 +398,9 @@ export default function ComponentLibrary() {
|
||||
/>
|
||||
<ParticipantPicker
|
||||
options={peopleOptions}
|
||||
getOptionValue={getPersonValue}
|
||||
getOptionLabel={getPersonLabel}
|
||||
getOptionSubtitle={getPersonSubtitle}
|
||||
value={participants}
|
||||
onChange={setParticipants}
|
||||
placeholder="Large"
|
||||
@ -365,6 +416,9 @@ export default function ComponentLibrary() {
|
||||
<div className="flex flex-col gap-md">
|
||||
<ParticipantPicker
|
||||
options={peopleOptions}
|
||||
getOptionValue={getPersonValue}
|
||||
getOptionLabel={getPersonLabel}
|
||||
getOptionSubtitle={getPersonSubtitle}
|
||||
value={participants}
|
||||
onChange={setParticipants}
|
||||
placeholder="Sök..."
|
||||
@ -374,6 +428,9 @@ export default function ComponentLibrary() {
|
||||
/>
|
||||
<ParticipantPicker
|
||||
options={peopleOptions}
|
||||
getOptionValue={getPersonValue}
|
||||
getOptionLabel={getPersonLabel}
|
||||
getOptionSubtitle={getPersonSubtitle}
|
||||
value={participants}
|
||||
onChange={setParticipants}
|
||||
placeholder="Sök..."
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user
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 theCombobox/this component.