List components #31

Merged
stne3960 merged 68 commits from list_item into main 2025-12-18 12:41:13 +01:00
4 changed files with 148 additions and 87 deletions
Showing only changes of commit bbe1012b80 - Show all commits

View File

@ -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}

View File

@ -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>

View File

@ -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
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.
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)}
/>

View File

@ -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..."